JavaScript and forms
Pending, success, and error states
Design explicit submission states so a visitor knows whether the form is working, succeeded, or needs attention.
A form submitted with fetch() has at least four states: idle, pending, success, and failure. The browser used to show all of them for free with a page load. Now that’s your job.
Make the states visible
Add a place for the status message next to the submit button:
<button type="submit">Send message</button>
<p id="form-status" aria-live="polite"></p>
The aria-live="polite" attribute turns the paragraph into a live region. When its text changes, screen readers announce it without moving focus. A spinner alone tells a sighted person something is happening and tells everyone else nothing.
Pending
When the request starts, disable the submitter and change its label:
const button = event.submitter
const status = document.querySelector('#form-status')
button.disabled = true
button.textContent = 'Sending…'
status.textContent = 'Sending your message'
Disable only the button that started the request. The rest of the form stays as it is. Don’t clear the fields here. If the request fails, the person needs their text back.
Success
Wait for the server to confirm. A response arriving is not the same as the operation succeeding, so check response.ok and read the body before you celebrate:
if (response.ok) {
status.textContent = 'Message sent. We reply within two days.'
}
Three kinds of failure
They look the same to fetch() at first glance, but they need different messages:
- A validation response (
422) should name the fields and keep their values. The person fixes one thing and resubmits. - A network failure (the promise rejected) should say the request didn’t go through and invite a retry. Nothing about the input was wrong.
- A server failure (
500) should say something went wrong on your side. Don’t blame the input.
Put the message in the live region either way, and give it a visible style that doesn’t rely on color alone.
Always restore the button
Wrap the whole thing so the cleanup runs no matter what:
try {
// fetch and handle the response
} catch (error) {
status.textContent = 'Could not send. Check your connection and try again.'
} finally {
button.disabled = false
button.textContent = 'Send message'
}
Without finally, a single thrown error leaves the button disabled forever. The visitor sees a form that stopped working and has no way to recover except a reload.
Disabling is not duplicate protection
A disabled button prevents an accidental double-click. It doesn’t prevent a browser retry, a network replay, or someone calling the endpoint directly. If two identical requests would cause damage, the server needs its own defense.
Try this: throttle the connection to “Slow 3G” in DevTools and watch every transition happen. Then block the request URL and confirm the form shows an error and the button comes back.
Lesson completed