AJAX in Drupal Forms: Dependent Selects and Add-More Fieldsfor Drupal 11 , and 10

Last updated :  

You've now built forms that reload a full page on every step. But some interactions feel wrong with a full reload — picking a temperature and having a color list appear instantly, or adding another text field to a list without losing your place. That's what Drupal's built-in AJAX framework is for, and the good news is: you don't write any JavaScript to use it. You describe the behavior in PHP, and Drupal generates the JavaScript for you. This lesson closes out the Form API topic with two real AJAX patterns.

What you'll learn in this lesson

  • How the #ajax property turns any element into a live, reactive one
  • The dependent-select pattern: one field's choice determines another field's options
  • The add-more pattern: letting visitors add or remove fields dynamically
  • Why AJAX callbacks never manipulate data — they only return already-built markup
  • The full request/response cycle happening behind the scenes
Both forms below share a common parent class, DemoBase, which provides a default submitForm() that echoes back whatever was submitted — so neither example needs to duplicate that logic.

The source files

  • modules/form_api_example/src/Form/AjaxColorForm.php
  • modules/form_api_example/src/Form/AjaxAddMore.php

AjaxColorForm.php

<?php

namespace Drupal\form_api_example\Form;

use Drupal\Core\Form\FormStateInterface;

/**
 * Implements the ajax demo form controller.
 *
 * This example demonstrates using ajax callbacks to populate the options of a
 * color select element dynamically based on the value selected in another
 * select element in the form.
 *
 * @see \Drupal\Core\Form\FormBase
 * @see \Drupal\Core\Form\ConfigFormBase
 */
class AjaxColorForm extends DemoBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'form_api_example_ajax_color_demo';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

    $form['description'] = [
      '#type' => 'item',
      '#markup' => $this->t('This form example demonstrates functioning of an AJAX callback.'),
    ];

    // The #ajax attribute used in the temperature input element defines an ajax
    // callback that will invoke the 'updateColor' method on this form object.
    // Whenever the temperature element changes, it will invoke this callback
    // and replace the contents of the 'color_wrapper' container with the
    // results of this method call.
    $form['temperature'] = [
      '#title' => $this->t('Temperature'),
      '#type' => 'select',
      '#options' => $this->getColorTemperatures(),
      '#empty_option' => $this->t('- Select a color temperature -'),
      '#ajax' => [
        // Could also use [get_class($this), 'updateColor'].
        'callback' => '::updateColor',
        'wrapper' => 'color-wrapper',
      ],
    ];

    // Add a wrapper that can be replaced with new HTML by the ajax callback.
    // This is given the ID that was passed to the ajax callback in the '#ajax'
    // element above.
    $form['color_wrapper'] = [
      '#type' => 'container',
      '#attributes' => ['id' => 'color-wrapper'],
    ];

    // Add a color element to the color_wrapper container using the value
    // from temperature to determine which colors to include in the select
    // element.
    $temperature = $form_state->getValue('temperature');
    if (!empty($temperature)) {
      $form['color_wrapper']['color'] = [
        '#type' => 'select',
        '#title' => $this->t('Color'),
        '#options' => $this->getColorsByTemperature($temperature),
      ];
    }

    // Add a submit button that handles the submission of the form.
    $form['actions'] = [
      '#type' => 'actions',
      'submit' => [
        '#type' => 'submit',
        '#value' => $this->t('Submit'),
      ],
    ];

    return $form;
  }

  /**
   * Ajax callback for the color dropdown.
   */
  public function updateColor(array $form, FormStateInterface $form_state) {
    return $form['color_wrapper'];
  }

  /**
   * Returns colors that correspond with the given temperature.
   *
   * @param string $temperature
   *   The color temperature for which to return a list of colors. Can be either
   *   'warm' or 'cool'.
   *
   * @return array
   *   An associative array of colors that correspond to the given color
   *   temperature, suitable to use as form options.
   */
  protected function getColorsByTemperature($temperature) {
    return $this->getColors()[$temperature]['colors'];
  }

  /**
   * Returns a list of color temperatures.
   *
   * @return array
   *   An associative array of color temperatures, suitable to use as form
   *   options.
   */
  protected function getColorTemperatures() {
    return array_map(function ($color_data) {
      return $color_data['name'];
    }, $this->getColors());
  }

  /**
   * Returns an array of colors grouped by color temperature.
   *
   * @return array
   *   An associative array of color data, keyed by color temperature.
   */
  protected function getColors() {
    return [
      'warm' => [
        'name' => $this->t('Warm'),
        'colors' => [
          'red' => $this->t('Red'),
          'orange' => $this->t('Orange'),
          'yellow' => $this->t('Yellow'),
        ],
      ],
      'cool' => [
        'name' => $this->t('Cool'),
        'colors' => [
          'blue' => $this->t('Blue'),
          'purple' => $this->t('Purple'),
          'green' => $this->t('Green'),
        ],
      ],
    ];
  }

}

AjaxAddMore.php

<?php

namespace Drupal\form_api_example\Form;

use Drupal\Core\Form\FormStateInterface;

/**
 * Implements the ajax demo form controller.
 *
 * This example demonstrates using ajax callbacks to add people's names to a
 * list of picnic attendees.
 *
 * @see \Drupal\Core\Form\FormBase
 * @see \Drupal\Core\Form\ConfigFormBase
 */
class AjaxAddMore extends DemoBase {

  /**
   * Form with 'add more' and 'remove' buttons.
   *
   * This example shows a button to "add more" - add another textfield, and
   * the corresponding "remove" button.
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $form['description'] = [
      '#type' => 'item',
      '#markup' => $this->t('This example shows an add-more and a remove-last button.'),
    ];

    // Gather the number of names in the form already.
    $num_names = $form_state->get('num_names');
    // We have to ensure that there is at least one name field.
    if ($num_names === NULL) {
      $name_field = $form_state->set('num_names', 1);
      $num_names = 1;
    }

    $form['#tree'] = TRUE;
    $form['names_fieldset'] = [
      '#type' => 'fieldset',
      '#title' => $this->t('People coming to picnic'),
      '#prefix' => '<div id="names-fieldset-wrapper">',
      '#suffix' => '</div>',
    ];

    for ($i = 0; $i < $num_names; $i++) {
      $form['names_fieldset']['name'][$i] = [
        '#type' => 'textfield',
        '#title' => $this->t('Name'),
      ];
    }

    $form['names_fieldset']['actions'] = [
      '#type' => 'actions',
    ];
    $form['names_fieldset']['actions']['add_name'] = [
      '#type' => 'submit',
      '#value' => $this->t('Add one more'),
      '#submit' => ['::addOne'],
      '#ajax' => [
        'callback' => '::addMoreCallback',
        'wrapper' => 'names-fieldset-wrapper',
      ],
    ];
    // If there is more than one name, add the remove button.
    if ($num_names > 1) {
      $form['names_fieldset']['actions']['remove_name'] = [
        '#type' => 'submit',
        '#value' => $this->t('Remove one'),
        '#submit' => ['::removeCallback'],
        '#ajax' => [
          'callback' => '::addMoreCallback',
          'wrapper' => 'names-fieldset-wrapper',
        ],
      ];
    }
    $form['actions']['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Submit'),
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'form_api_example_ajax_add_more';
  }

  /**
   * Callback for both ajax-enabled buttons.
   *
   * Selects and returns the fieldset with the names in it.
   */
  public function addMoreCallback(array &$form, FormStateInterface $form_state) {
    return $form['names_fieldset'];
  }

  /**
   * Submit handler for the "add-one-more" button.
   *
   * Increments the max counter and causes a rebuild.
   */
  public function addOne(array &$form, FormStateInterface $form_state) {
    $name_field = $form_state->get('num_names');
    $add_button = $name_field + 1;
    $form_state->set('num_names', $add_button);
    // Since our buildForm() method relies on the value of 'num_names' to
    // generate 'name' form elements, we have to tell the form to rebuild. If we
    // don't do this, the form builder will not call buildForm().
    $form_state->setRebuild();
  }

  /**
   * Submit handler for the "remove one" button.
   *
   * Decrements the max counter and causes a form rebuild.
   */
  public function removeCallback(array &$form, FormStateInterface $form_state) {
    $name_field = $form_state->get('num_names');
    if ($name_field > 1) {
      $remove_button = $name_field - 1;
      $form_state->set('num_names', $remove_button);
    }
    // Since our buildForm() method relies on the value of 'num_names' to
    // generate 'name' form elements, we have to tell the form to rebuild. If we
    // don't do this, the form builder will not call buildForm().
    $form_state->setRebuild();
  }

  /**
   * Final submit handler.
   *
   * Reports what values were finally set.
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValue(['names_fieldset', 'name']);

    $output = $this->t('These people are coming to the picnic: @names', [
      '@names' => implode(', ', $values),
    ]
    );
    $this->messenger()->addMessage($output);
  }

}

How it works

The #ajax property, in general

Add an #ajax array to almost any form element, and Drupal attaches JavaScript that intercepts its change or click event and sends an asynchronous request instead of a full page submit — no hand-written JavaScript required. Two keys matter:

  • 'callback' — the PHP method Drupal calls server-side once the request arrives. 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.

AjaxColorForm: one field controls another's options

$form['temperature'] = [
  '#type' => 'select',
  '#options' => $this->getColorTemperatures(),
  '#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->getColorsByTemperature($temperature),
  ];
}

The moment someone picks a temperature, Drupal rebuilds the whole form server-side with the new value already in $form_state. Because buildForm() checks $form_state->getValue('temperature') every time it runs, the color select simply doesn't exist until a temperature has been chosen — no special AJAX-only logic needed, just an ordinary conditional.

public function updateColor(array $form, FormStateInterface $form_state) {
  return $form['color_wrapper'];
}

Here's the detail that surprises most people learning this pattern for the first time: the AJAX callback does no work at all. All the real logic — deciding which colors to show — already happened in buildForm() by the time updateColor() runs. The callback's only job is to hand back the already-correct piece of the rebuilt $form array so Drupal can render it to HTML and swap it into #color-wrapper.

AjaxAddMore: letting visitors add and remove fields

$num_names = $form_state->get('num_names');
if ($num_names === NULL) {
  $form_state->set('num_names', 1);
  $num_names = 1;
}
$form['#tree'] = TRUE;

Just like the multistep form from the last lesson, this relies on a counter stashed in $form_state that survives across AJAX round-trips. $form['#tree'] = TRUE is new, though: it tells Drupal to preserve the nested shape of the submitted values instead of flattening everything to the top level — without it, $form_state->getValue(['names_fieldset', 'name']) further down wouldn't work.

for ($i = 0; $i < $num_names; $i++) {
  $form['names_fieldset']['name'][$i] = [
    '#type' => 'textfield',
    '#title' => $this->t('Name'),
  ];
}

A plain PHP loop generates exactly as many text fields as num_names says. Since buildForm() runs fresh on every rebuild, changing that one number is the entire mechanism behind fields appearing and disappearing.

$form['names_fieldset']['actions']['add_name'] = [
  '#type' => 'submit',
  '#value' => $this->t('Add one more'),
  '#submit' => ['::addOne'],
  '#ajax' => [
    'callback' => '::addMoreCallback',
    'wrapper' => 'names-fieldset-wrapper',
  ],
];

This button combines two mechanisms you've now seen separately: a dedicated #submit handler (from the multistep form lesson) and an #ajax callback. That combination matters — the submit handler runs first and actually changes the state:

public function addOne(array &$form, FormStateInterface $form_state) {
  $name_field = $form_state->get('num_names');
  $form_state->set('num_names', $name_field + 1);
  $form_state->setRebuild();
}

It increments the counter and calls setRebuild() — familiar from the multistep lesson — which forces buildForm() to run again before the AJAX callback fires. Only then does addMoreCallback() get invoked, and by that point the freshly-rebuilt $form already contains the new field:

public function addMoreCallback(array &$form, FormStateInterface $form_state) {
  return $form['names_fieldset'];
}

Same principle as updateColor() earlier: the callback does no work, it just hands back the relevant slice of an already-correct form. Both the "Add one more" and "Remove one" buttons share this exact same callback — the only difference between them is which submit handler ran beforehand.

Notice the "Remove one" button only appears when $num_names > 1. Since buildForm() re-evaluates that condition on every rebuild, the button appears and disappears automatically — no extra JavaScript required to hide or show it.
The AJAX add-more picnic form with two Name fields filled in (Ada Lovelace, Grace Hopper) and both Add one more and Remove one buttons visible

One click on "Add one more" and the form now has two Name fields, both preserving what was already typed, plus the "Remove one" button that only exists once $num_names > 1 — exactly the state buildForm() produced after addOne() incremented the counter and forced a rebuild.

The AJAX request/response cycle, start to finish

  1. A visitor interacts with an AJAX-enabled element (changes a select, clicks a button).
  2. Drupal's own JavaScript sends a POST request to its internal AJAX endpoint with the current form values.
  3. Server-side, Drupal fully rebuilds the form via buildForm() using the current $form_state.
  4. For button clicks, any #submit handler on that specific button runs first, potentially modifying $form_state.
  5. If setRebuild() was called, buildForm() runs again with the updated state.
  6. The AJAX callback runs and returns a slice of the rebuilt $form array.
  7. Drupal renders that slice to HTML and wraps it in a JSON response.
  8. The browser replaces the inner HTML of the matching wrapper element — no full page reload anywhere in this sequence.

See it for yourself

Visit /examples/form-api-example/ajax-color-demo on your DDEV site and pick a temperature from the dropdown.

The AJAX color form showing Temperature set to Warm and the Colour dropdown populated with Red, Orange, Yellow

Watch the network tab in your browser's dev tools while you do it — you'll see a POST request fire off, and the Color dropdown appears without the page ever reloading.

Quick check: in updateColor(), why doesn't the method need to know which temperature was selected? Because that decision already happened inside buildForm() before the callback ever runs — the callback just returns whatever buildForm() already built.

Key takeaways

  • The #ajax property turns any element's change or click into an asynchronous request — Drupal generates all the JavaScript; you only supply a callback and a wrapper id.
  • AJAX callbacks never manipulate data — they only return the slice of the already-rebuilt $form array that should replace the wrapper. All real logic lives in buildForm() or dedicated submit handlers.
  • Wrap the target region in an element with a stable id, either a container with #attributes => ['id' => '...'], or #prefix/#suffix on any element.
  • Use $form_state->set()/get() to track counters or flags across AJAX round-trips, exactly like you did for multi-step forms.
  • When a button's own action needs to change state before the AJAX response, give it a dedicated #submit handler that calls $form_state->setRebuild() — this forces buildForm() to run again before the callback fires.
  • Set $form['#tree'] = TRUE whenever elements are nested inside fieldsets or containers and you need to read values with a nested path like $form_state->getValue(['parent', 'child']).

Coming up next

You've now built forms from the ground up — single-field, multi-field, multi-step, and reactive. In the next topic, we widen the lens beyond forms specifically and look at Drupal's AJAX framework as a general-purpose tool: updating any part of a page, not just form elements, from links and buttons that aren't tied to a form at all.