3.6 KiB
3.6 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| Surface Server Errors via setError('root.serverError', ...) | HIGH | prevents lost server-side validation errors and unrecoverable form state | valid, server-errors, setError, async, error-handling |
Surface Server Errors via setError('root.serverError', ...)
handleSubmit does not catch errors thrown inside async submit handlers — it logs them and silently leaves the form unrecoverable (isSubmitting stays true if you throw). The canonical pattern is to try/catch inside the submit handler and route API failures into setError. Use field-level setError(name, ...) when the server tells you which field is wrong; use setError('root.serverError', ...) for general failures (network error, 500, "Account is locked").
Incorrect (server error is thrown, swallowed, and form is now stuck):
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormData>()
const onSubmit = async (data: LoginFormData) => {
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) throw new Error('Login failed') // Lost: no UI feedback, isSubmitting stuck
redirect('/dashboard')
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input type="password" {...register('password')} />
<button disabled={isSubmitting}>Sign in</button>
</form>
)
}
Correct (server errors surfaced via setError, form stays recoverable):
function LoginForm() {
const {
register,
handleSubmit,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>()
const onSubmit = async (data: LoginFormData) => {
clearErrors('root.serverError')
try {
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const body = await res.json()
if (body.field === 'password') {
setError('password', { type: 'server', message: body.message })
} else {
setError('root.serverError', { type: 'server', message: body.message ?? 'Sign in failed' })
}
return
}
redirect('/dashboard')
} catch {
setError('root.serverError', { type: 'network', message: 'Network error — please retry' })
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root?.serverError && (
<div role="alert">{errors.root.serverError.message}</div>
)}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>Sign in</button>
</form>
)
}
Key details:
- Root-level errors live under
errors.root.{key}— pick any key (serverError,network,rateLimit) and read it back the same way - Root errors persist across submissions until you call
clearErrors('root.serverError')— clear at the start of each submit, or rely on the next resolver pass to overwrite - Always
try/catchasync submit handlers.handleSubmitwill not surface thrown errors, andisSubmittingonly resets when the handler returns (resolves), not when it throws — see alsoformstate-async-submit-lifecycle
Reference: setError · Discussion #9691 — Handle global/server errors