Implementing AJAX callbacks for dynamic formsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

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 #ajax property 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 HTML id of 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.

Common mistake: giving the wrapper's 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 in buildForm() — 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

  • #ajax needs a callback (the server-side method) and a wrapper (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 $form array; the logic lives in buildForm().
  • 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().