Forms & Server Actions
Controlled vs uncontrolled inputs, React Hook Form + Zod, async validation, surfacing server errors, optimistic updates, and React 19 / Next.js 16 Server Actions end-to-end.
Forms & Server Actions
In one line: A form collects input, validates it, and sends it to the server to change something. The modern pattern wires a real
<form>directly to a server action — one function that runs on the server, validates, mutates, and returns errors — so it works even before JavaScript loads.
→ Going deeper: forms lean heavily on the two-kinds-of-state idea — State Management covers where form state sits versus server and client state.
Most of a real app is forms: sign-up, login, checkout, "leave a comment," "edit your profile." A form has three jobs:
- Collect what the user types.
- Validate it — both in the browser (fast, friendly) and on the server (the only check you can trust).
- Submit it to the server to actually do something (create the account, save the comment).
Beginners often build forms by hand with a tangle of useState and fetch. The 2026 approach is two well-worn tools: React Hook Form + Zod for the input/validation layer, and Server Actions (React 19 / Next.js 16) for the submit-and-mutate layer. This page teaches both, then traces one sign-up form all the way through.
Terms, defined once
- Controlled input — A form field whose value lives in React state. React is the single source of truth; every keystroke updates state and re-renders.
value={x}+onChange. - Uncontrolled input — A field that keeps its own value in the DOM. You read it only when you need it (on submit), via a
refor the form'sFormData. The browser is the source of truth. FormData— A built-in browser object holding a form's field names and values. A plain HTML form submit produces one automatically; server actions receive it directly.- Schema — A declared description of what valid data looks like (e.g. "email must be an email; password ≥ 8 chars"). With Zod, the schema is the validation and generates the TypeScript type.
- Server Action — A function that runs only on the server, callable from a form or a component without you writing a separate API route. Marked with the
"use server"directive. - Progressive enhancement — The form works with plain HTML first (no JS), then gets better when JavaScript loads (inline errors, no full-page reload). It never requires JS to function.
- Optimistic update — Showing the result immediately — before the server confirms — then reconciling when the real response arrives. Makes the UI feel instant.
Controlled vs uncontrolled inputs
This is the first fork every form hits. Take a single text field.
Controlled — value lives in React state:
function NameField() {
const [name, setName] = useState('');
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
Every keystroke runs setName, re-renders the component, and re-paints the input from state. You always know the current value, and you can react to it live (character counters, instant validation). The cost: a render per keystroke, and you wire value + onChange on every field.
Uncontrolled — value lives in the DOM, read on demand:
function NameField() {
const ref = useRef<HTMLInputElement>(null);
// read ref.current.value only when you submit
return <input ref={ref} defaultName="" />;
}
No re-render per keystroke; you grab the value at submit time. Less code, faster, but you can't easily react to the value as it changes.
Hand-rolling controlled inputs for a 12-field form means 12 useStates, 12 onChanges, and a re-render of the whole form on every keystroke. React Hook Form (RHF) uses uncontrolled inputs under the hood (refs) for performance, but gives you a controlled-feeling API. You get the speed of uncontrolled with the convenience of controlled — which is why it's the default in 2026.
React Hook Form + Zod — the validation layer
You met this pairing briefly in State Management. Here's the full picture.
Zod declares one schema. That schema does triple duty: runtime validation, the TypeScript type, and (via a resolver) the form's field-level errors.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
});
// One line gives you the TypeScript type for free:
type SignupValues = z.infer<typeof SignupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({ resolver: zodResolver(SignupSchema) });
const onSubmit = (values: SignupValues) => {
// values is fully typed and already validated
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Sign up</button>
</form>
);
}
In English:
register('email')connects the input to RHF using the field name as the key (novalue/onChangeneeded). On submit,handleSubmitruns thezodResolver, which validates the values againstSignupSchema. Any failures land inerrors, keyed by field name, so you render the message right next to the offending input.isSubmittingflips totruewhile your submit handler runs — wire it to the button'sdisabledso users can't double-submit. Therole="alert"makes a screen reader announce the error the moment it appears.
Async / server-side validation
Some rules can't be checked in the browser — "is this email already taken?" only the server knows. Zod supports async refinements:
const SignupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
}).refine(
async (data) => !(await emailTaken(data.email)),
{ message: 'That email is already registered', path: ['email'] },
);
In English:
.refine()adds a custom rule. Because the check isasync(it calls the server), Zod awaits it during validation, andpath: ['email']attaches the failure to the email field so it renders in the right place. The key principle below: the browser check is a courtesy; the server check is the law. Never trust client-side validation alone — a malicious user can bypass your JavaScript entirely.
Server Actions — the modern mutation pattern
Before Server Actions, submitting a form meant: write an API route (/api/signup), fetch it from the client with JSON.stringify, parse the response, handle errors by hand. Server Actions collapse that into one function.
A Server Action is a function marked "use server". You can pass it straight to a form's action prop. React serializes the submit into FormData, calls your function on the server, and you mutate the database directly — no API route, no fetch, no client/server contract to keep in sync.
// app/actions.ts
'use server';
import { SignupSchema } from './schema';
export async function signup(prevState, formData: FormData) {
// 1. Validate on the server (the trustworthy check)
const parsed = SignupSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
});
if (!parsed.success) {
// Return field errors back to the form
return { errors: parsed.error.flatten().fieldErrors };
}
// 2. Mutate (create the user, hash the password, etc.)
await createUser(parsed.data);
// 3. Return success (or redirect)
return { success: true };
}
In English:
'use server'at the top marks every export as a server function.safeParsevalidates without throwing —parsed.successtells you whether it passed, and.flatten().fieldErrorsgives you a{ email: [...], password: [...] }object that maps cleanly back onto the form. Notice the schema is the same Zod schema the client uses — one source of truth, validated on both sides.
Wiring it up: useActionState and useFormStatus
Two React 19 hooks connect the form to the action:
useActionState— Calls your action and tracks its return value (state) plus apendingflag. It's how you read back the server's errors or success.useFormStatus— Read from inside a child component (e.g. a submit button) to know whether the parent form is currently submitting. Lets you build a reusable<SubmitButton>that disables itself.
// app/signup-form.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { signup } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Creating…' : 'Sign up'}</button>;
}
export function SignupForm() {
const [state, formAction] = useActionState(signup, { errors: {} });
return (
<form action={formAction}>
<input name="email" />
{state.errors?.email && <span role="alert">{state.errors.email[0]}</span>}
<input name="password" type="password" />
{state.errors?.password && <span role="alert">{state.errors.password[0]}</span>}
<SubmitButton />
</form>
);
}
In English:
useActionState(signup, initialState)returns the lateststatereturned by the action and aformActionyou hand to<form action={...}>. When the form submits, React posts theFormDatatosignupon the server, and whatever it returns becomes the newstate— that's how server errors surface in the UI.<SubmitButton>callsuseFormStatus()to read the form'spendingstate without prop-drilling. Crucially, because the form uses the nativeactionattribute, it submits even with JavaScript disabled — that's progressive enhancement, for free.
Optimistic updates
For things that almost always succeed (liking a post, adding a comment), don't make the user wait. useOptimistic shows the result instantly, then reconciles:
'use client';
import { useOptimistic } from 'react';
function Comments({ comments, addComment }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, newComment) => [...state, { ...newComment, pending: true }],
);
async function action(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic({ text }); // show it immediately
await addComment(formData); // server action confirms (or the UI rolls back on error)
}
return (
<>
{optimistic.map((c) => (
<p key={c.text} style={{ opacity: c.pending ? 0.5 : 1 }}>{c.text}</p>
))}
<form action={action}>
<input name="text" />
<button>Post</button>
</form>
</>
);
}
In English:
useOptimistickeeps a temporary version of the list with the new comment added (dimmed viapending). The user sees their comment instantly. When the server action resolves, React replaces the optimistic state with the real data; if the action throws, the optimistic entry is discarded and the UI reverts automatically.
Worked example, traced: a sign-up form end to end
Let's follow one submission through every stage.
Trace, step by step, for input email: "ada@", password: "12345":
- Type & blur. RHF runs
zodResolver. Zod sees"ada@"fails.email()and"12345"fails.min(8).errors.email.message = "Enter a valid email",errors.password.message = "At least 8 characters". Both render in red under their fields. Submit is blocked client-side. - User fixes input to
email: "ada@site.com",password: "averylongpass". Client validation passes. The native<form action={formAction}>submits theFormData. - Server Action fires. On the server,
signupcallsSignupSchema.safeParse(...). Now an async refinement runsemailTaken("ada@site.com")against the database. Suppose it returnstrue. - Server returns errors.
parsed.successisfalse;signupreturns{ errors: { email: ["That email is already registered"] } }.useActionStateupdatesstate, and the email field now shows the server's message — even though the client thought the input was fine. This is the server-error-surfacing pattern in action. - User picks a new email, resubmits.
safeParsepasses,createUserwrites the row, the action returns{ success: true }(orredirect('/welcome')). Done — and the whole flow worked without a single hand-written API route orfetchcall.
Why it matters
Forms are where users change your app's data, which makes them the highest-stakes surface you build: a broken form is a lost signup, a lost sale, or a security hole. The Server Actions pattern matters because it (1) removes the boilerplate (no API route + fetch + JSON plumbing), (2) keeps one validation schema running on both client and server, and (3) is progressively enhanced, so the form still works on a flaky connection or before JS hydrates. Knowing this pattern cold is table stakes for a Next.js job in 2026.
Common mistake
- Trusting client-side validation. RHF + Zod in the browser is for fast feedback, not security. A user can disable JS or hit your action directly. Always re-validate inside the server action with the same schema — the server check is the only one that counts.
- Reaching for
useState+fetch+ a hand-written API route in 2026. That's the pre-2024 pattern. For mutations in Next.js, a server action is less code, type-safe, and progressively enhanced. Write the API route only when a third party needs the endpoint. - Duplicating the schema. Writing one Zod schema for the form and a different ad-hoc check on the server guarantees they drift. Define the schema once (a shared file), import it in both the RHF resolver and the server action.
- Forgetting the
pending/isSubmittingstate. Without disabling the button while the action runs, users double-click and you create two accounts. WireuseFormStatus().pending(or RHF'sisSubmitting) to the button'sdisabled. - Optimistic updates with no rollback.
useOptimisticreverts automatically if the action throws — but if your action swallows the error and returns "success," the bad state sticks. Let failures throw (or return an error the UI checks) so the optimistic entry rolls back. - Mismatched field
names. Server Actions readformData.get('email')by the input'snameattribute, not RHF'sregisterkey. If thenamedoesn't match what the action expects, you'll readnull. Keep them identical.
Page checkpoint
Did forms & server actions stick?
RequiredWhat's next
→ Continue to Design Systems & Storybook — how teams build the reusable, accessible components your forms (and everything else) are made of.