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_statesurvives across AJAX round-trips - Why an "add more" button needs both a
#submithandler and an#ajaxcallback - What
$form_state->setRebuild()actually does, and why it's essential here - Why
$form['#tree'] = TRUEmatters 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.
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()fromaddOne(), what would actually break? (Answer:buildForm()would never run again before the AJAX callback fires, so the callback would return the samenames_fieldsetwith the old field count — the counter would silently increment in$form_statebut 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
#submithandler (to change state) and an#ajaxcallback (to return the updated markup) — the submit handler runs first. $form_state->setRebuild()forcesbuildForm()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'] = TRUEwhenever 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.