Errors: schema & server
This page covers formstand's two-channel error model: the validation-owned schema channel, the app-owned server channel, the derived errors map your UI reads, the exact rules for when a server error is released, root-level errors, focusing the first errored input, and how submit behaves while server errors are present.
The two channels
Error state is stored in two separate channels, with distinct owners:
state.schemaErrors— owned by validation. Every validation pass rebuilds it: full passes replace it wholesale, field-scoped passes splice only their scope. Your app never writes it.state.serverErrors— owned by you.setError/setErrorswrite it andclearErrorsclears it. Validation never reads or writes this channel.
That ownership split is the preservation guarantee. A background validateAsync() finishing after your submit handler set a "username taken" message physically cannot wipe it — it can't touch the channel the message lives in. There are no "manual error" marks or bookkeeping flags to get wrong.
form.handleSubmit(async (data) => {
const res = await api.create(data);
if (!res.ok) {
for (const err of res.errors) {
// setError's path is typed; a server-provided string needs a cast
form.setError(err.field as FieldPath<z.input<typeof schema>>, err.message);
}
}
});The derived errors map
state.errors — the map useField, useIsValid, and the bound components read — is derived from the two channels on every write, under one merge rule:
- The schema's message wins at a key: it re-judged the same value the server did, and it judged it more recently by definition (validation reruns; the server verdict is a snapshot).
- Server entries show where the schema is silent.
The merge is order-independent. Calling setError on a key the schema currently rejects stores the verdict (visible in state.serverErrors) behind the schema message. If the schema later clears at that key without the value changing — say a cross-field refine stopped firing — the stored server verdict resurfaces, because the value it judged is unchanged. You don't have to sequence your writes around validation.
Keys are dot paths ("users.0.email"), values are readonly string[]. Root-level messages live at the "" key (see below).
Writing the server channel
form.setError("username", "taken"); // single string...
form.setError("username", ["taken", "reserved"]); // ...or an array
form.setError("username", []); // empty array removes the entry
form.setErrors({ username: ["taken"], "": ["account limit reached"] });
// replaces the WHOLE server channel (schema errors persist until the next pass)
form.clearErrors("address"); // clears BOTH channels at "address" and its descendants
form.clearErrors(""); // clears just the root "" entry
form.clearErrors(); // clears everything, both channelsNote the asymmetry: setError/setErrors touch only the server channel, but clearErrors scrubs both — schema errors it removes simply return on the next validation pass.
From a field, useField exposes the same surface scoped to its path: field.setError("taken") (a single string or an array, same as form.setError) and field.clearError().
When a server error is released
A server verdict describes a specific value. It's automatically released when:
- The value on its spine changes.
setValue/resetField/ an array op at the path, a descendant, or an ancestor releases it — editingaddress.streetreleases a verdict onaddress(the container it judged changed), and replacingaddresswholesale releases a verdict onaddress.street. Array ops release verdicts on the array itself, while row-level entries follow their rows through re-indexing (a verdict onitems.1.namemoves toitems.0.namewhen row 0 is removed — that row's value didn't change). - A
setValuesbulk write changes its value slice. Only entries whose slice actually changed are released; a verdict on an untouched field survives asetValuesthat rewrites its siblings. - A field-scoped validation targets its path.
validateField/validateFieldAsync/validateFieldson the path supersede the server verdict — you explicitly asked for a fresh judgment there. (validateField("")counts as a full pass and, likevalidate(), leaves the server channel alone.) - You clear or rebase:
clearErrors,reset, oradoptValues.
Nothing else releases it — in particular, full-form validation passes never do.
Root errors: the "" key
A schema-level .refine (or a form-level server failure you record with setError("", ...)) has no field path; its messages live at the "" key:
const schema = z
.object({ password: z.string(), confirm: z.string() })
.refine((v) => v.password === v.confirm, { message: "passwords must match" });Read it with the shortcut hook:
import { useFormError } from "formstand";
const FormError = ({ form }: { form: FormStateApi }) => {
const error = useFormError(form); // state.errors[""]
return error ? <p role="alert">{error[0]}</p> : null;
};Because any value write makes a form-level verdict stale, the root "" server entry is released by every value write.
Focusing the first error
focusFirstError(errors, root?) focuses the first control in DOM order whose name attribute matches an errored path — exactly, or as a descendant of an errored container path, so an array-level error like z.array().min(1) lands on the array's first rendered input. The bound components set name={path}, so this works out of the box:
<form onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors))}>Matching is most-specific-first: the root "" key falls back to focusing the first control only when no field-keyed error matches anything — a form-wide refine must not steal focus from an actually errored field. Controls that can't take focus are skipped: hidden and disabled ones (a leading <input type="hidden" name="csrf"> won't swallow the fallback) and anything inside a closed <dialog>; if a candidate refuses focus anyway (e.g. display: none), the next match in DOM order is tried. The function returns true only when a control actually holds focus, and is safe to import during SSR.
On a page with several forms, pass the form element as root (e.g. via a ref) so the search stays inside your form:
<form
ref={formRef}
onSubmit={form.handleSubmit(onValid, (errors) =>
focusFirstError(errors, formRef.current ?? undefined),
)}
>Without a root the whole document is scanned — and the root-"" fallback refuses to guess between forms: when the document holds more than one <form>, it returns false instead of focusing an arbitrary first control.
Submit proceeds despite server errors
submit re-validates against the schema only. If the schema passes, your onValid handler runs even while server errors are present — the server gets to re-judge the submission. The stale verdicts simply remain in the merged map (so useIsValid stays false) until the user edits the field, a field-scoped validation targets it, or your handler clears/overwrites them.
Also note the other direction: a failed submit writes schema errors and marks each errored field touched, so touched-gated error UIs show something after the first failed submit.
Patching state: errors is derived
updateState(updater) applies an atomic multi-field patch — but since errors is derived, the patch type omits it entirely. Patch the channels instead, and the merged map is re-derived for you:
form.updateState((state) => ({
serverErrors: { ...state.serverErrors, username: ["taken"] },
touched: { ...state.touched, username: true },
}));A plain-JavaScript caller that sneaks an errors patch through gets a console warning and the patch is ignored. The same invariant holds at the restore(snapshot) boundary: the merged map is re-derived from the snapshot's channels, never trusted as stored.
Next
- Validation — the passes that write the schema channel.
- Form state & lifecycle —
updateState,snapshot/restore,resetvsadoptValues.