Engineering
React 19 quietly resets your form, and when that bites
Our intake form posts through a server action. During testing, one rejected field came back to a page where everything the client had typed was gone, including a signature they had drawn with a finger. React 19 resets forms after an action on purpose, and the reasoning is sound. Here is why the reset exists, where it hurts, and the small submit pattern that keeps typed input on the page.
The short version
- React 19 resets a form's uncontrolled fields after a <form action> submission completes, and it does this on purpose.
- If your server action returns a validation error, the user sees that error next to a freshly emptied form.
- The reset exists for parity with the no-JavaScript web, where posting a form always lands on a page with fresh empty fields.
- The fix keeps action for progressive enhancement and adds an onSubmit that validates on the client, then dispatches the action manually inside startTransition with a FormData snapshot.
- useFormStatus does not see a manually dispatched action, so read pending state from useActionState's isPending instead.
Bookatu lets a salon attach an intake form to a service, and clients fill it in on their phone before the appointment. These forms are long by design. Name and contact details, a date of birth, a run of acknowledgement boxes, and at the end a signature the client draws with a finger. The form posts through a React server action, and the server validates everything again before saving, because client-side checks are a courtesy and the server is the authority.
During testing we mistyped an email address on purpose and submitted. The server rejected it, exactly as designed. The error message came back fine. The form did not. Every field was empty. The acknowledgement boxes were unticked. The hidden input holding the signature was cleared, while the drawing itself still sat on the canvas, so the screen showed a signed form that would fail again on submit. One wrong character had cost the whole page of input.
Why React does this
Our first instinct was to call it a bug. It is documented behaviour. In React 19, when a form is submitted through the action prop and the action finishes, React resets the form's uncontrolled fields. The reasoning comes from progressive enhancement. A form wired to a server action is supposed to work before JavaScript hydrates, or with JavaScript off entirely. In that world the browser performs a full page post, the server responds, and the new page arrives with a fresh, empty form. That has been the shape of the web since forms existed.
So React makes the JavaScript path match. After a client-side action submission completes, it resets the form, the same way a full page round trip would have. For a search box, a comment field, or an add-a-note form, this is exactly what you want. Submit, clear, ready for the next entry. The framework cannot know whether your action succeeded or returned an error state, because to React both are just a completed action with a return value. It resets either way.
A reset that mirrors the no-JavaScript web is a good default. A wiped form after a server error is a price users should not pay for it.
When it bites
Two things have to be true at once. The form has to be long enough that retyping it hurts, and the server has to be able to reject it. Most demos of server actions have neither, which is why this surprises people in production rather than in tutorials. A ten-field medical intake form on a phone keyboard is close to the worst case. Nobody fills that in twice without resenting it, and plenty will simply leave.
Custom widgets make it worse. Our signature pad is a canvas that serialises the drawing into a hidden input, which is a common shape for anything the browser has no native control for. The reset clears the hidden input, but nothing tells the canvas to clear its pixels, so the visible state and the submitted state now disagree. The user sees their signature and cannot understand why the form keeps saying it is missing. Any widget that stores its value in a hidden field has this failure mode, whether it is a signature, a colour picker, or a drag-to-reorder list.
One more trap: the fields most likely to fail server-side validation are the ones client-side validation cannot fully cover. An email address that looks fine but belongs to an account that already exists. A booking slot that was free when the page loaded and is taken now. You cannot validate those in the browser, so some server rejections will always happen, and each one used to cost the whole form.
The fix: intercept when JavaScript is there
The pattern we settled on keeps both worlds. The action prop stays on the form, so a submission that happens before hydration, or with JavaScript disabled, still posts and still works. On top of that we add an onSubmit handler. When JavaScript is running, the handler fires first, prevents the default submission, validates everything on the client, and only then dispatches the action manually. React only auto-resets a form when the form itself fires the action. A manual dispatch skips the reset entirely, so the fields keep whatever the user typed.
"use client";
import { startTransition, useActionState, useState } from "react";
import { saveIntake } from "./actions";
type IntakeState = {
ok: boolean;
fieldErrors: Record<string, string>;
};
const initial: IntakeState = { ok: false, fieldErrors: {} };
export function IntakeForm() {
const [state, submitAction, isPending] = useActionState(saveIntake, initial);
const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
// With JavaScript running, always take over. Without it, this
// handler never fires and the plain action path below still works.
event.preventDefault();
// Snapshot the fields synchronously, before anything async runs.
const data = new FormData(event.currentTarget);
const errors = validateIntake(data);
if (Object.keys(errors).length > 0) {
setClientErrors(errors);
return; // nothing dispatched, nothing reset, input untouched
}
setClientErrors({});
startTransition(() => {
// Manual dispatch: React does not auto-reset the form for this.
submitAction(data);
});
}
return (
<form action={submitAction} onSubmit={handleSubmit}>
{/* fields, plus a signature widget writing to a hidden input */}
<button type="submit" disabled={isPending}>
{isPending ? "Saving" : "Submit"}
</button>
</form>
);
}The client-side validation is worth doing properly here, and it has to reach the places native validation cannot. The required attribute does nothing for a hidden input, so every custom widget needs an explicit check, or an empty signature sails straight through to the server and triggers the very rejection you were trying to avoid.
function validateIntake(data: FormData): Record<string, string> {
const errors: Record<string, string> = {};
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) errors.email = "Enter a valid email address.";
// Custom widgets live in hidden inputs, so `required` never fires.
// Check them by hand.
const signature = String(data.get("signature") ?? "");
if (!signature) errors.signature = "Please sign before submitting.";
return errors;
}The details that will trip you up
- Take the FormData snapshot synchronously, at the top of the handler. After an await, event.currentTarget may no longer point at the form, and by then the DOM could have changed under you.
- The manual dispatch must be wrapped in startTransition. The function returned by useActionState expects to run inside one, and calling it bare will warn and can drop the pending state.
- useFormStatus will not report pending for a manual dispatch. It only tracks submissions the form itself initiated. Read isPending from useActionState instead, which is the third element of the tuple.
- Keep the server validation exactly as strict as before. The client checks exist to catch the common mistakes early. Anything that depends on server state, like a slot that just got taken, will still come back as an action error, and now it comes back to a form that still holds the user's input.
The road we did not take
There is another documented way out: have the action return every submitted value in its state, and thread each one back into the matching field's defaultValue, so the post-reset form re-fills itself from the server's echo. We tried it and disliked it. Every new field needs its value plumbed through the action's return type, someone will forget one, and the approach does nothing for the signature canvas, which is not a value you can hand back through defaultValue. Round-tripping data the browser already had, just to survive a reset, felt like solving the problem from the wrong end.
Making every input controlled would also work, and for a small form it is a fine answer. For a long intake form it means state, a change handler, and a re-render per keystroke for dozens of fields, in exchange for behaviour the intercept pattern gives us in one place.
The lesson we keep relearning is that framework defaults encode an assumption about your form. React 19 assumes a completed action means the form's job is done. For most forms that is right. For a long form where the server can say no, you have to say otherwise, and one onSubmit handler is all it takes to say it.
Building on Bookatu?
Bookatu has a public REST API and webhooks. Have a look at the developer docs.
Developer docs