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
| Layer | Job |
|---|---|
| Client validation | Fast feedback, fewer useless requests |
| Server validation | Source of truth, security, business rules |
| Form library | Less boilerplate for large forms |
Common mistakes
- Placeholder-only “labels”
- Errors not linked with
aria-describedby - Trusting only client checks
- Storing derived
isValidin 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)
Related
Final recommendation
Ship forms that are labeled, explicit about errors, and validated on both sides. Libraries are optional; accessibility and server checks are not.