Server-side errors
Treat API failures the same as schema failures: same reactive surface, same
firstErrorreads, same focus / scroll behavior on submit.
- Category
- Helper + Return methods
- Parser
parseApiErrors(envelope, { formKey })- Mounters
setFieldErrors · addFieldErrors · clearFieldErrors- Returns
{ ok: true, errors } | { ok: false, reason }
Submit with taken@example.com as the email and admin as the username to watch the simulated server response route through parseApiErrors → setFieldErrors. The errors land at errors.email and errors.username, the field-level firstError surfaces them next to the inputs, and the form-level focus pull treats them like any other invalid path. Submit with different values to see the success path fire.
The flow
A successful round-trip lands a value at the form; a failed one needs to land an error at the right path. The three pieces:
parseApiErrors(envelope, { formKey }): normalizes the server response intoValidationError[].form.setFieldErrors(errors)(orform.addFieldErrors): mounts the parsed errors into the form's reactive surface.form.clearFieldErrors(path?): drops them when the user starts editing or the next submit fires.
import { parseApiErrors } from 'attaform'
const onSubmit = form.handleSubmit(async (values) => {
form.clearFieldErrors()
const response = await api.signup(values)
if (!response.ok) {
const parsed = parseApiErrors(response, { formKey: form.key })
if (parsed.ok) {
form.setFieldErrors(parsed.errors)
return
}
}
// success path
})
parseApiErrors
parseApiErrors(
envelope: ApiErrorEnvelope,
options: { formKey: string }
): { ok: true; errors: ValidationError[] } | { ok: false; reason: string }
ApiErrorEnvelope is the shape the server returns: a wrapped object with a details array of { path, message } entries. The parser:
- Validates the envelope shape; returns
{ ok: false, reason }when it doesn't conform. - Maps each entry's
pathto the form's path tuple. - Stamps each error with the form key for cross-form isolation.
When the response is a plain 200 { ok: true }, skip the call entirely; parseApiErrors is for failure paths.
Mounting errors
Three methods land parsed errors into the form:
| Method | Use |
|---|---|
setFieldErrors(errors) | Replace the current error set with the supplied list. Use after a fresh server round-trip. |
addFieldErrors(errors) | Append to the existing set without clearing prior entries. Use when reporting an additional issue alongside existing schema errors. |
clearFieldErrors(path?) | Drop all errors (no arg) or just one path's errors. Use when the user edits a field that previously had a server error. |
form.setFieldErrors(parsedErrors)
form.addFieldErrors([{ path: ['email'], message: 'Already registered', code: 'custom' }])
form.clearFieldErrors('email')
form.clearFieldErrors() // clear everything
Same reactive surface
Once mounted, server errors are indistinguishable from schema errors at the read surfaces:
form.errors.emailreturns theValidationError[](server or schema, same shape).form.fields.email.firstErrorreturns the first one.form.fields.email.showErrorsgates display per thegetDisplayStatepredicate.form.focusFirstError()pulls focus to the first server error just like a schema one.
No special "this is a server error" surface in the template. The render code reads form.fields.<path>.firstError?.message the same way for both kinds.
Auto-clear on edit
By default, editing a field after a server error landed at that path does NOT auto-clear the error: it'll persist until the next submit re-runs or form.clearFieldErrors fires explicitly. Most servers want a fresh round-trip before the error is "cleared," so this matches the network round-trip semantics.
For "clear on edit" UX, hook a watcher on the path and call clearFieldErrors(path):
watch(
() => form.values.email,
() => form.clearFieldErrors('email')
)
Where to next
handleSubmit: the dispatch surface server errors plug into.- Focus & scroll on invalid submit: same machinery, applied to server errors after mount.
errors: the reactive read surface for every error, server or schema.