buildForm runs before ajax button's submit handler on first click First of all, here is some working example code for the classic "Add more" AJAX button (complete module available as a gist):

public function buildForm(array $form, FormStateInterface $form_state) {

  if (!$form_state-&gt;get('number_of_fields')) {
    $form_state-&gt;set('number_of_fields', 1);
  }

  $form['#tree'] = TRUE;
  $form['container'] = [
    '#type' =&gt; 'container',
    '#id' =&gt; 'replace-me',
  ];

  for ($i = 0; $i &lt; $form_state-&gt;get('number_of_fields'); $i++) {
    $form['container'][$i] = [
      'my_text' =&gt; [
        '#type' =&gt; 'textfield',
        '#title' =&gt; $this-&gt;t('Any text'),
      ],
    ];
  }

  $form['add'] = [
    '#type' =&gt; 'submit',
    '#value' =&gt; t('Add another'),
    '#submit' =&gt; [[$this, 'addTextFieldCallback']],
    '#ajax' =&gt; [
      'callback' =&gt; [$this, 'ajaxReplaceMeCallback'],
      'wrapper' =&gt; 'replace-me',
    ],
  ];

  $form['submit'] = [
      '#type' =&gt; 'submit',
      '#value' =&gt; t('Submit'),
  ];

  return $form;
}

public function addTextFieldCallback(array &amp;$form, FormStateInterface $form_state) {
  $form_state-&gt;set('number_of_fields', $form_state-&gt;get('number_of_fields') + 1);
  $form_state-&gt;setRebuild();
}

public function ajaxReplaceMeCallback(array &amp;$form, FormStateInterface $form_state) {
  return $form['container'];
}


Now I want to save more things in the form storage. For example, I would want to generate a unique id for the container, so I can include my form more than once per page:

if (!$form_state-&gt;get('unique_id')) {
  $form_state-&gt;set('unique_id', Html::getUniqueId('replace-me'));
}

// ...

$form['container'] = [
  '#type' =&gt; 'container',
  '#id' =&gt; $form_state-&gt;get('unique_id'),
];

// ...

$form['add'] = [
  '#type' =&gt; 'submit',
  '#value' =&gt; t('Add another'),
  '#submit' =&gt; [[$this, 'addTextFieldCallback']],
  '#ajax' =&gt; [
    'callback' =&gt; [$this, 'ajaxReplaceMeCallback'],
    'wrapper' =&gt; $form_state-&gt;get('unique_id'),
  ],
];


However, this does not work. Using xdebug and phpstorm, I found out why:

When I load the page containing the form, buildForm runs. It generates a unique ID. First time round, the ID is replace-me. All good.

The first time I click on "Add another", the following functions run, in this order:


buildForm 
validateForm
addTextFieldCallback
buildForm
ajaxReplaceMeCallback


The first time buildForm runs, $form_state-&gt;getStorage() is empty, which is unexpected! Consequently, a new ID is generated, e.g. replace-me--62or3gVN0K0. This means that the AJAX won't find the right node to replace next time, since the button still points to the old ID, replace-me.

The second time I click on "Add another", the following functions run, in this order:


validateForm
addTextFieldCallback
buildForm
ajaxReplaceMeCallback


This time, it works as expected, and the ID stays the same, but functionality is broken because the ID shouldn't have changed in the first place.

Questions:


Is this normal behaviour, or have I discovered a D8 bug?
What am I doing wrong?
Why would buildForm run twice on the first click?
Yes, this the normal behavior. In D7 and earlier in Drupal 8, having a form with at least one #ajax element forced the form to be stored in "cache", which quickly resulted in a huge amount of data there (we had GB of data in key_value_expire in an very early D8 site and it was growing almost faster than we could keep deleting it)

Now this only happens on the first Ajax request, when the form actually needs to start storing data.

The only thing you can do is depend your key on something that is more or less stable, you're possibly editing something that has an ID or you have a loop displaying multiple forms, then you can pass in an index. In Simplenews where we display multiple forms in blocks and we generate a unique Id (or allow the user to provide one) that we can then use to identify the forms.

Note that you need a unique form_id anyway to be able to use the same form more than once per page, see Generate unique form ID based on context.