Events and forms
Model async form states
Represent idle, submitting, success, and failure without allowing duplicate work or hiding errors.
A form that talks to the server has four states: idle, submitting, success, failure. Every one of them needs to be visible in the state, and the form must get back to idle no matter what happens.
The issue editor saves with fetch(). Let’s model the whole lifecycle.
The shortcut that breaks
The first version most people write:
async save() {
this.saving = true
await fetch('/issues/42', { method: 'PUT', body: new FormData(this.$el) })
this.saving = false
}
Works on the happy path. Now unplug the network. fetch throws, the line after it never runs, and saving stays true forever. The button is disabled, no error shows, and the user reloads the page.
try, catch, finally
The fix is a shape, not a trick. Do the work in try, show the error in catch, reset in finally:
<form x-data="{
title: 'Login button unresponsive on Safari',
saving: false,
error: '',
async save() {
if (this.saving) return
this.saving = true
this.error = ''
try {
const res = await fetch('/issues/42', {
method: 'PUT',
body: new FormData(this.$el)
})
if (!res.ok) throw new Error(await res.text())
} catch (e) {
this.error = e.message
} finally {
this.saving = false
}
}
}" @submit.prevent="save()">
<input name="title" x-model="title">
<button :disabled="saving" x-text="saving ? 'Saving…' : 'Save'"></button>
<p role="alert" x-show="error" x-text="error"></p>
</form>
finally runs whether the request succeeded, the server said 422, or the network died. saving always goes back to false.
Keep server errors visible
fetch only throws on network failure. A 422 Unprocessable Content response with “Title is required” resolves normally. That’s why the code checks res.ok and throws with the body text.
The error lands in a <p role="alert">. That role makes screen readers announce the text when it appears. A red border alone tells nobody what went wrong.
Block the double click
The if (this.saving) return line at the top is deliberate. :disabled="saving" handles the mouse, but the guard also covers Enter pressed twice and any other path into save(). One request per submit, always.
Test the four states
Don’t trust the happy path. In the browser’s Network panel:
- throttle to Slow 3G and click Save. The button reads “Saving…” and is disabled.
- return a 422 from the server. The message appears, the button re-enables, the title stays editable.
- go offline and click Save. Same result, with “Failed to fetch” as the message.
- double-click Save online. One request in the Network panel, not two.
Run those four on your own form. If any one fails, the lifecycle has a hole.
Lesson completed