A full-page reload for every small interaction feels like restarting a conversation from scratch just to answer a follow-up question. Drupal's #ajax property fixes that: add it to almost any form element, and Drupal generates the JavaScript that intercepts the change, sends it to the server, and swaps in a fresh piece of the page — no full reload, and no hand-written JavaScript required.
What you'll learn in this lesson
- What the
#ajaxproperty does and the two keys it needs - How to wire a dependent-select pattern — one field's choice changes another field's options
- Why the AJAX callback method itself does no real logic
- The wrapper element that makes the swap possible
A dependent-select form
$form['temperature'] = [
'#type' => 'select',
'#title' => $this->t('Temperature'),
'#options' => ['warm' => $this->t('Warm'), 'cool' => $this->t('Cool')],
'#empty_option' => $this->t('- Select -'),
'#ajax' => [
'callback' => '::updateColor',
'wrapper' => 'color-wrapper',
],
];
$form['color_wrapper'] = [
'#type' => 'container',
'#attributes' => ['id' => 'color-wrapper'],
];
$temperature = $form_state->getValue('temperature');
if (!empty($temperature)) {
$form['color_wrapper']['color'] = [
'#type' => 'select',
'#title' => $this->t('Color'),
'#options' => $this->getColorsFor($temperature),
];
}
public function updateColor(array $form, FormStateInterface $form_state) {
return $form['color_wrapper'];
}
The two keys #ajax actually needs
callback— the method Drupal calls server-side once the AJAX request lands. The::prefix means "a method on this form class."wrapper— the HTMLidof an element already on the page whose inner content gets replaced with whatever the callback returns.
The callback does no real work — the surprise beginners hit first
Notice updateColor() doesn't decide anything. All the real logic — which colors to show — already happened in buildForm(), because Drupal rebuilds the entire form server-side the moment the AJAX request arrives, with the new temperature value already present in $form_state. The callback's only job is handing back the piece of the freshly rebuilt $form that should replace the wrapper's contents.
The wrapper: a stable target to swap into
The color-wrapper container exists purely so there's a fixed id in the rendered HTML for Drupal's AJAX JavaScript to find and replace. Without it declared up front, there'd be nowhere for the swapped-in color select to land — the wrapper always needs to exist in the form, even before the color field itself does.
id as anything other than a plain string matching what's in #attributes => ['id' => '...']. A mismatch here fails silently in the browser — no error, the callback just never visibly updates anything.Quick check: why doesn't
updateColor()need to know which temperature was picked? Because that decision was already made inbuildForm()— by the time the AJAX callback runs,$form_state->getValue('temperature')has already determined what's in$form['color_wrapper']. The callback just returns what's already correct.
Key takeaways
#ajaxneeds acallback(the server-side method) and awrapper(the target element's HTML id) — Drupal generates all the JavaScript.- AJAX callbacks never contain real logic — they return a slice of the already-rebuilt
$formarray; the logic lives inbuildForm(). - The wrapper element must exist in the form from the start, even if its contents are conditional.
- This same pattern (dependent select) generalizes to add-more buttons, live previews, and any other "change one thing, update another" form behavior.
Coming up next
You've built forms from scratch — now flip the perspective. Next, we look at how to reach into and modify a form your module doesn't own at all, using hook_form_alter().