Picture a hotel booking form. Most guests never see a "special accommodation" section at all — it would just be clutter for someone booking a standard room. But the moment a guest checks "I need special accommodations," a whole new set of fields should appear: dietary restrictions, accessibility needs, room-location preferences. Hand-writing JavaScript to show and hide that block would work, but Drupal's Form API gives you a declarative shortcut that does the exact same job in pure PHP: the #states property.
What you'll learn in this lesson
- What
#statesactually is, and why it's a PHP-only alternative to hand-written JavaScript - The three-part shape every
#statesarray follows: state, selector, condition - How to bind one element's visibility to another element's value
- Why
#statesonly works reliably on elements with a real, unique HTMLnameattribute
The source file
Path (relative to the Examples module's root): modules/form_api_example/src/Form/StateDemo.php
$form['needs_accommodation'] = [
'#type' => 'checkbox',
'#title' => 'Need Special Accommodations?',
];
$form['accommodation'] = [
'#type' => 'container',
'#attributes' => [
'class' => 'accommodation',
],
'#states' => [
'invisible' => [
':input[name="needs_accommodation"]' => ['checked' => FALSE],
],
],
];
$form['accommodation']['diet'] = [
'#type' => 'textfield',
'#title' => $this->t('Dietary Restrictions'),
];
How it works
The three parts of a #states array
'#states' => [
'invisible' => [
':input[name="needs_accommodation"]' => ['checked' => FALSE],
],
],
Read it from the outside in. 'invisible' is the state being controlled — the thing that happens to this element. :input[name="needs_accommodation"] is a jQuery selector pointing at some other element on the page — the thing being watched. ['checked' => FALSE] is the condition — the exact circumstance under which the state applies. Put in plain English: "make this container invisible whenever the checkbox named needs_accommodation is unchecked." When the box gets checked, the condition stops matching, and the container becomes visible again — automatically, with zero JavaScript written by you.
Why :input[name="..."], not #some-id
Drupal auto-generates a fresh HTML id for every rendered element to avoid collisions on a page with multiple copies of the same form. That makes id selectors unreliable for #states. The name attribute, by contrast, is exactly what Form API uses to submit values — it's stable, predictable, and directly tied to the render-array key you gave the element. That's why every #states selector in this course targets name, never id.
Behind the scenes: drupal_process_states()
You never call this function yourself — Drupal's Form API renderer does, automatically, for every element carrying a #states key. It converts your PHP array into a small blob of JSON, attaches Drupal core's own drupal.states JavaScript library to the page, and that library does the actual watching and toggling in the browser. The practical result: you write one associative array in PHP, and get real, responsive client-side behavior for free.
#states only controls how an element looks — it does not enforce anything server-side. A visitor with JavaScript disabled (or a malicious one crafting a raw POST request) can still submit a value for a field that was supposed to be hidden. If a hidden field's value genuinely must never be trusted, validate it in validateForm() too — #states is a UX convenience, not a security boundary.See it for yourself
Visit /examples/form-api-example/state-demo on your own DDEV site. The "Dietary Restrictions" field starts hidden; check "Need Special Accommodations?" and watch it appear instantly, with no page reload.
Quick check: if you changed the condition from
['checked' => FALSE]to['checked' => TRUE], what would happen to the form's default appearance? (Answer: the logic would flip — the dietary field would be visible by default and disappear once the checkbox is checked, which is almost certainly not what you want here. The condition describes exactly when the state applies, so getting the boolean backwards silently inverts the whole interaction.)
Key takeaways
#statesis a PHP-only way to bind one element's behavior (visibility, and others you'll meet in the next few lessons) to another element's live value, with Drupal core generating the JavaScript for you.- Every
#statesarray follows the same shape:[state => [selector => condition]]. - Always select by
:input[name="..."], not byid— Drupal's auto-generated IDs aren't reliable targets. #statesonly affects presentation in the browser; it is never a substitute for real server-side validation of untrusted input.
Coming up next
You've now seen the visible/invisible pair in action. The next lesson zooms in specifically on hiding and showing fields — including a subtlety between invisible and its close cousin hidden that trips up a lot of developers the first time they meet it.