Updating Form Elements via AJAX: The Add-More Patternfor Drupal 8 , 9 , 10 , and 11

Last updated :  

So far every AJAX example has updated a wrapper's contents with something new. This lesson covers a related but distinct trick: letting visitors add or remove entire form elements dynamically — a growing (or shrinking) list of fields, driven entirely by clicking a button, no page reload. The classic example: a guest list where "Add one more" grows the form by one field every time it's clicked.

What you'll learn in this lesson

  • How a counter stashed in $form_state survives across AJAX round-trips
  • Why an "add more" button needs both a #submit handler and an #ajax callback
  • What $form_state->setRebuild() actually does, and why it's essential here
  • Why $form['#tree'] = TRUE matters once your fields are nested inside a fieldset

The counter that drives everything

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

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 request — including every AJAX request — changing that one number in $form_state is the entire mechanism behind fields appearing and disappearing. $form['#tree'] = TRUE tells Drupal to preserve the nested shape of submitted values instead of flattening everything to the top level; without it, reading back $form_state->getValue(['names_fieldset', 'name']) later wouldn't work.

A button that combines #submit and #ajax

$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 does two distinct things, in a strict order. The dedicated #submit handler runs first and actually changes 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, then calls setRebuild() — this is the critical line. Without it, Drupal would treat the click as a normal form submission and never call buildForm() again before the AJAX callback fires. With it, buildForm() runs a second time, sees the incremented counter, and generates one more field. Only then does the AJAX callback run:

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

Same thin-callback principle from earlier lessons — by the time this runs, the freshly-rebuilt $form already contains the new field. The callback just hands back the relevant slice.

Both "Add one more" and "Remove one" share this exact same addMoreCallback(). The only difference between the two buttons is which #submit handler ran beforehand — one increments the counter, the other decrements it (with a floor of 1, so you can never remove the last field). The AJAX callback itself doesn't need to know or care which button was clicked.

Quick check: if you removed $form_state->setRebuild() from addOne(), what would actually break? (Answer: buildForm() would never run again before the AJAX callback fires, so the callback would return the same names_fieldset with the old field count — the counter would silently increment in $form_state but nothing new would appear on the page.)

Key takeaways

  • Store a counter (or any piece of state you need to persist) in $form_state->set()/get() — it survives across AJAX round-trips.
  • An "add more" button needs both a dedicated #submit handler (to change state) and an #ajax callback (to return the updated markup) — the submit handler runs first.
  • $form_state->setRebuild() forces buildForm() to run again with the updated state before the AJAX callback fires — skip it and your state change never reaches the rendered form.
  • Set $form['#tree'] = TRUE whenever your dynamic elements are nested inside a fieldset or container.

Coming up next

Every example so far assumed the AJAX request succeeds cleanly. The next lesson covers what happens — and what you should do — when it doesn't.