Skip to content

TopicsIntermediate

React Forms: Validation, Errors, and Accessibility

React forms with practical validation: controlled inputs, inline errors, accessibility, and when to validate on the client vs the server.

Mohamed ElshahawyPublished Oct 2026
2 min read

What you'll learn

  • Validate for UX on the client and for trust on the server
  • Associate errors with inputs via labels and ids
  • Derive validity during render when you can
  • Checklist before shipping a form

Direct answer: use controlled inputs (or a form library) with visible labels, show field-level errors next to the control, disable or explain submit when invalid, and always re-validate on the server. Client checks improve UX; server checks protect data.

When to use this

  • Signup, settings, and checkout flows
  • Any form that can fail server-side after a client pass

When not to use this

  • A single search box with no persistence: keep it local and simple
  • You need schema-heavy multi-step wizards: consider a dedicated form library, still following the same a11y rules

Practical example

JSX
function SignupForm({ onSubmit }) {
  const [email, setEmail] = React.useState('')
  const [password, setPassword] = React.useState('')

  const emailError =
    email.length > 0 && !email.includes('@') ? 'Enter a valid email' : null
  const passwordError =
    password.length > 0 && password.length < 8 ? 'Use at least 8 characters' : null
  const canSubmit = email.includes('@') && password.length >= 8

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        if (!canSubmit) return
        onSubmit({ email, password })
      }}
    >
      <label htmlFor="email">Email</label>
      <input
        id="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-invalid={Boolean(emailError)}
        aria-describedby={emailError ? 'email-error' : undefined}
      />
      {emailError ? (
        <p id="email-error" role="alert">
          {emailError}
        </p>
      ) : null}

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        aria-invalid={Boolean(passwordError)}
        aria-describedby={passwordError ? 'password-error' : undefined}
      />
      {passwordError ? (
        <p id="password-error" role="alert">
          {passwordError}
        </p>
      ) : null}

      <button type="submit" disabled={!canSubmit}>
        Create account
      </button>
    </form>
  )
}

Derive errors during render when they come from field values. Avoid syncing isValid through useEffect (Anti-Patterns).

Comparison

LayerJob
Client validationFast feedback, fewer useless requests
Server validationSource of truth, security, business rules
Form libraryLess boilerplate for large forms

Common mistakes

  • Placeholder-only “labels”
  • Errors not linked with aria-describedby
  • Trusting only client checks
  • Storing derived isValid in state

Checklist

  • Every input has a visible <label>
  • Errors are announced and tied to fields
  • Submit is blocked or explained when invalid
  • Server rejects bad payloads even if the client was bypassed
  • Loading and error states exist for submit (Loading patterns)

Final recommendation

Ship forms that are labeled, explicit about errors, and validated on both sides. Libraries are optional; accessibility and server checks are not.