AJAX-Driven Dependent Fields: Chaining Multiple Selectsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

You've already built a two-level dependent select — temperature narrows down which colors appear. Real forms often need to go one level further: country narrows state, state narrows city, each selection cascading into the next. The good news: nothing new is required. The exact same pattern from earlier — an #ajax-enabled select, a wrapper, and a callback that returns that wrapper — just gets applied twice in a row.

What you'll learn in this lesson

  • How to chain the same dependent-select pattern across three levels instead of two
  • Why each intermediate field needs its own wrapper, not just the final one
  • What happens to a "grandchild" field when its parent selection changes
  • A gotcha specific to chains: stale values left behind in $form_state

Extending the two-level pattern to three

$form['country'] = [
  '#type' => 'select',
  '#title' => $this->t('Country'),
  '#options' => $this->getCountries(),
  '#empty_option' => $this->t('- Select a country -'),
  '#ajax' => [
    'callback' => '::updateState',
    'wrapper' => 'state-wrapper',
  ],
];

$form['state_wrapper'] = [
  '#type' => 'container',
  '#attributes' => ['id' => 'state-wrapper'],
];

$country = $form_state->getValue('country');
if (!empty($country)) {
  $form['state_wrapper']['state'] = [
    '#type' => 'select',
    '#title' => $this->t('State'),
    '#options' => $this->getStatesByCountry($country),
    '#ajax' => [
      'callback' => '::updateCity',
      'wrapper' => 'city-wrapper',
    ],
  ];

  $form['state_wrapper']['city_wrapper'] = [
    '#type' => 'container',
    '#attributes' => ['id' => 'city-wrapper'],
  ];

  $state = $form_state->getValue('state');
  if (!empty($state)) {
    $form['state_wrapper']['city_wrapper']['city'] = [
      '#type' => 'select',
      '#title' => $this->t('City'),
      '#options' => $this->getCitiesByState($state),
    ];
  }
}

Read this the same way you read the two-level version: each conditional only adds its field once the field above it has a value. The country select's callback (updateState) returns $form['state_wrapper'] — the same "return the already-correct slice" pattern as before. The state select, once it exists, gets its own #ajax array pointing at a nested city-wrapper, with its own callback (updateCity) that returns $form['state_wrapper']['city_wrapper'].

Every intermediate level needs its own wrapper and its own callback. It's tempting to think one callback could handle the whole chain, but each #ajax-enabled select only ever knows how to replace the one wrapper it's configured with. A three-level chain genuinely needs two separate callback methods — one per transition — even though both follow the identical thin-callback pattern.

The stale-value gotcha

Picture this sequence: a visitor picks Country A, then State X, then City 1. They then change the country to Country B. The state select correctly repopulates with Country B's states — but if your code isn't careful, $form_state can still be holding onto the old "State X" value from before, and the city dropdown briefly shows options that don't belong to any state the visitor has actually selected under the new country. The fix is straightforward but easy to forget: when a parent selection changes, explicitly clear the stored value of anything beneath it in the chain, rather than assuming it'll sort itself out.

Quick check: in a three-level country/state/city chain, how many separate #ajax-enabled elements and how many callback methods do you need in total? (Answer: two of each — the country select triggers a callback that reveals the state select, and the state select triggers its own separate callback that reveals the city select. The city select itself doesn't need an #ajax property, since nothing depends on it.)

Key takeaways

  • A multi-level dependent-select chain is just the same two-level pattern applied once per transition — no new mechanism required.
  • Every intermediate field needs its own wrapper element and its own dedicated callback method.
  • Watch for stale values surviving in $form_state when an earlier selection in the chain changes — clear downstream values explicitly rather than assuming they'll reset.
  • The final field in the chain (the one nothing else depends on) never needs its own #ajax property.

Coming up next

Every example so far has triggered AJAX from a select element's change event. The next — and final — lesson in this section covers attaching #ajax directly to submit buttons themselves.