Files
gruperly/.agents/skills/react-hook-form/references/valid-server-errors.md
2026-09-04 16:49:24 -03:00

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/catch async submit handlers. handleSubmit will not surface thrown errors, and isSubmitting only resets when the handler returns (resolves), not when it throws — see also formstate-async-submit-lifecycle

Reference: setError · Discussion #9691 — Handle global/server errors