How do I download a dynamically-created file in a form submission?
I was off-track in even trying to do this with AJAX, as Clive mentioned in his comment. Instead, I just made it a normal submit button, and then overrode the form action. The result is that the data is downloaded as a file, the form doesn't reload, and the data output section doesn't update, which is the desired behavior in this case.
The button, along with a new format element:
$form['download']['format'] = [
  '#type' =&gt; 'select',
  '#options' =&gt; $this-&gt;getValidFormats(),
  '#title' =&gt; $this-&gt;t('Select format'),
  '#weight' =&gt; '0',
];
$form['download']['download'] = [
  '#type' =&gt; 'submit',
  '#value' =&gt; $this-&gt;t('Download'),
  '#weight' =&gt; '10',
];

The submit function:
public function submitForm(array &amp;$form, FormStateInterface $form_state) {
  switch ($form_state-&gt;getValue('op')-&gt;__toString()) {
    case 'Download':
      $this-&gt;download($form, $form_state);
      break;
  }
}

And the relevant part of the download function:
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;

$response = new Response($file_content);
$disposition = $response-&gt;headers-&gt;makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'data.' . $format);
$response-&gt;headers-&gt;set('Content-type', $content_types[$format]);
$response-&gt;headers-&gt;set('Content-Disposition', $disposition);
$response-&gt;headers-&gt;set('Content-Transfer-Encoding', 'binary');
$response-&gt;headers-&gt;set('Content-length', strlen($file_content));
$form_state-&gt;setResponse($response);