TL;DR
- Form complexity is rarely about the input element itself. It is about state transitions: editing, validating, submitting, success, failure, retry, and reset.
- I default to explicit data flow over “magic” because real product forms always grow into async validation, server errors, optimistic updates, and partial failure.
- Client validation improves speed, but the server remains authoritative for business rules, uniqueness checks, pricing, and access control.
- Modern React form actions are great when the mutation belongs close to the route, but they still need deliberate error handling and pending-state design.
Context
Simple demo forms hide the problems that matter in production:
- duplicate submission
- stale validation state
- server-side business rule failures
- optimistic updates that need rollback
- form reset rules after success or navigation
- preserving partially entered work when the route refreshes
I design forms around those transitions early, because they are exactly where user trust is won or lost.
The Approach
1. Start by modeling the workflow, not the fields
These are the states I want visible in code:
- idle
- editing
- validating
- submitting
- success
- failure
If a form can meaningfully retry, autosave, or partially save, I model that explicitly instead of hiding it in booleans like isLoading and hasError.
2. Controlled inputs are still the clearest baseline when correctness matters
For smaller forms, I still like controlled inputs because every transition is obvious:
type SignupState = {
email: string
status: "idle" | "submitting" | "success"
error: string | null
}
const [state, setState] = useState<SignupState>({
email: "",
status: "idle",
error: null,
})
async function onSubmit(event: React.FormEvent) {
event.preventDefault()
if (!state.email.includes("@")) {
setState((current) => ({ ...current, error: "Enter a valid email" }))
return
}
setState((current) => ({ ...current, status: "submitting", error: null }))
try {
await createAccount({ email: state.email })
setState((current) => ({ ...current, status: "success" }))
} catch (error) {
setState((current) => ({
...current,
status: "idle",
error: error instanceof Error ? error.message : "Unable to create account",
}))
}
}
This is verbose, but the trade is clarity. On teams with lots of form bugs, clarity wins.
3. Escalate to reducers when transitions matter more than fields
Once the form includes async validation, multi-step navigation, or partial save, I usually stop adding one useState per field and move to a reducer.
type FormState = {
values: { name: string; email: string }
fieldErrors: Partial<Record<"name" | "email", string>>
submitError: string | null
status: "editing" | "submitting" | "success"
}
type Action =
| { type: "changed"; field: "name" | "email"; value: string }
| { type: "fieldErrors"; errors: FormState["fieldErrors"] }
| { type: "submitStarted" }
| { type: "submitFailed"; message: string }
| { type: "submitSucceeded" }
That gives me one place to reason about transition correctness and one place to unit test the workflow.
4. Keep server validation in the loop
Client validation is good for:
- required fields
- formatting
- cheap length checks
- obvious input constraints
Server validation is required for:
- uniqueness
- permissions
- pricing and inventory
- business rules
- tenant-specific policy
That means the form contract needs to support returning field errors and form-level errors separately.
type ActionResult = {
fieldErrors?: {
email?: string
}
formError?: string
}
async function submitSignup(data: { email: string }): Promise<ActionResult> {
const response = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
})
if (response.status === 409) {
return {
fieldErrors: { email: "An account already exists for this email" },
}
}
if (!response.ok) {
return {
formError: "Something went wrong. Please try again.",
}
}
return {}
}
5. Modern action-based forms are strong for route-local mutations
In current React and Next.js form flows, useActionState and useFormStatus are useful when the write path belongs close to the route.
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveProfile } from './actions'
const initialState = {
fieldErrors: {} as Record<string, string>,
message: "",
}
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>
}
export function ProfileForm() {
const [state, formAction] = useActionState(saveProfile, initialState)
return (
<form action={formAction}>
<input name="displayName" aria-invalid={Boolean(state.fieldErrors.displayName)} />
{state.fieldErrors.displayName && <p>{state.fieldErrors.displayName}</p>}
<SubmitButton />
{state.message && <p>{state.message}</p>}
</form>
)
}
This is a great fit when:
- the form belongs to one route
- progressive enhancement matters
- the server owns validation and mutation
- I want fewer custom client fetch wrappers
6. Optimistic UI should be rare and deliberate
I only use optimistic updates when rollback is clear. Good candidates:
- toggles
- likes
- lightweight preference changes
Poor candidates:
- checkout
- permission changes
- destructive or financial mutations
- anything with messy validation and cross-system dependencies
At senior level, the question is not “can I make it feel fast?” It is “can I recover safely when the fast path is wrong?”
Trade-offs
- Controlled forms are explicit, but they get noisy for very large forms unless I centralize state transitions.
- Reducers make workflow logic easier to test, but they add ceremony for small forms.
- Action-based forms reduce client boilerplate, but they can hide the network boundary if the team stops treating the mutation like a real server contract.
- Uncontrolled approaches can be lighter, but I lose some visibility when live validation and conditional UI get more complex.