Submission Handling
function LoginForm() {
const form = useForm({
initialValue: { email: "", password: "" },
onSubmit: async (form) => {
await login(form.value);
},
onSubmitError: (form, error) => {
toast.error("Failed to log in");
},
});
return <form onSubmit={form.handleSubmit}>{/* ... */}</form>;
}handleSubmit:
- Waits out any pending validation.
- If the form is invalid, marks it
touched(so errors on never-blurred fields become visible) and callsonSubmitInvalid, then returns. - Otherwise sets
submittingtotrue, callsonSubmit, and falls back toonSubmitErrorif it throws/rejects.
A no-op on a re-entrant call while already submitting. The event parameter
is optional and used only for preventDefault(), so the same call works from a
React Native onPress, any caller with no event, or a web <form onSubmit> as
shown above.
Server-side validation errors
Some checks only run on the server: uniqueness, cross-record rules, anything
needing data the client doesn't have. When onSubmit's request comes back with
per-field errors (an HTTP 422, typically), hand them to form.setErrors:
const form = useForm({
initialValue: { email: "", password: "" },
onSubmit: async (form) => {
const res = await fetch("/api/sign-up", {
method: "POST",
body: JSON.stringify(form.value),
});
if (res.status === 422) {
const { errors } = await res.json(); // { email: "Already registered" }
form.setErrors(errors);
}
},
});setErrors takes a flat, dot-joined path-to-message map, the same shape a
schemaValidator produces: "email",
"address.city", or "" for a message about the form as a whole. It's on every
node, so form.setErrors(...) addresses the whole tree while
form.field("address").setErrors(...) scopes to a subtree.
Each message:
- Lands only on a field already created with
field(); a path with no field is ignored. - Marks that field
touched, so it shows even if the user never focused it. - Outranks a
schemaValidatorerror on the same field, and clears the next time that field's value changes. Re-submitting is what refreshes it.
None of this is SSR-specific: it's the same call whether the form is
server-rendered or a plain client-side SPA. For a live "is this taken?" check
that runs while the user types rather than at submit, use
asyncValidator instead.
Disabling the submit button while submitting
submitting (and dirty, for a "nothing to save" state) are ordinary reactive
state, so gate the button like any other field property:
<Watch api={form} select={(f) => [f.submitting, f.dirty] as const}>
{(form, [submitting, dirty]) => (
<button type="submit" disabled={submitting || !dirty}>
Save
</button>
)}
</Watch>;Disabling the whole form while submitting
Set form.disabled = true around onSubmit's work.
disabled
cascades from a field down through every already-registered descendant, so
form.disabled = true reaches every field in the tree without watching
submitting anywhere:
const form = useForm({
initialValue: { email: "", password: "" },
onSubmit: async (form) => {
form.disabled = true;
try {
await login(form.value);
} finally {
form.disabled = false;
}
},
});disabled on its own only skips validation; it doesn't reach the DOM by itself.
For it to actually disable an input, the component rendering that input has to
read its own field's disabled and fold it into whatever disabled prop the
caller passed in, the same way TextField does (see
Basic):
const isDisabled = disabled || field.disabled;Each TextField is already subscribed to just its own field via useWatch, so
disabling a 50-field form during submit re-renders only the fields whose
disabled actually flipped, not LoginForm itself.
What's next
FormApi— full reference on JSR