JavaScript and forms

Submit a form with fetch

Send FormData or JSON with fetch, inspect the HTTP status, and update the interface only after the server confirms the result.

fetch() lets you submit a form without leaving the page. The visitor stays where they are, and you update the interface with the result.

Start from a native form that already works. Then take over its submit event:

form.addEventListener('submit', async event => {
  event.preventDefault()

  const data = event.submitter
    ? new FormData(form, event.submitter)
    : new FormData(form)

  const response = await fetch(form.action, {
    method: form.method,
    body: data,
    headers: {
      Accept: 'application/json'
    }
  })

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }

  const result = await response.json()
  console.log(result)
})

Notice we read form.action and form.method from the HTML instead of hardcoding them. The form stays the single source of truth for where the data goes. The Accept header tells the server we want JSON back, not a full HTML page.

fetch does not throw on a 400

This is the part that surprises people. fetch() resolves as soon as it gets any HTTP response. A 422 validation error and a 500 server crash both resolve normally. Only a network problem rejects the promise.

So always check response.ok (true for status 200 to 299) or look at response.status before treating the request as a success. Skip this and your form shows “Sent!” on top of a server error.

Agree on the response format

If the client asks for JSON, the server should answer with JSON for every expected outcome. Success looks like this:

{ "id": 842 }

A validation failure returns a 422 with the field errors:

{ "errors": { "email": "Enter a complete email address" } }

What you want to avoid is calling response.json() on an HTML error page. The parse fails with Unexpected token '<', and if you swallow that error the person sees nothing at all. Check the status first, then parse.

Two kinds of failure

A validation error means the person should fix a field. A network error means nothing reached the server and they should retry with the same values. Treat them differently. Wrap the fetch() call in try/catch, handle the rejection as “could not connect”, and handle a non-ok response as “the server said no”.

While it’s pending

Disable the submitter that started the request and show a status like “Sending…”. Restore the button in a finally block. Without finally, one thrown error leaves the form stuck with a disabled button. The next lesson covers these states in detail.

The trust boundary didn’t move

The server still validates every field, checks authorization, and protects against duplicates. fetch() changes how the interface feels. It doesn’t change who is responsible for the data.

Try this: test one successful submission, one that returns a validation error, one where the server returns a 500, and one with DevTools set to offline. Each should produce a different, understandable result.

Lesson completed