React.js, part 8: Server Actions — forms that talk to the server without a fetch
Part 8from the React.js series · 20 parts in all
A form submission used to be a small ceremony: prevent default, assemble a body, fetch, parse errors, branch on status, set state for each. Server Actions collapse that ceremony into a function the form can call directly — it runs on the server, and React drives the network and the pending UI for you.
An action is an async function marked as one
// app/actions.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
const Schema = z.object({ email: z.string().email(), name: z.string().min(1) });
export async function subscribe(formData) {
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await db.subscriber.create({ data: parsed.data }); // secret key, server-side
return { ok: true };
}
The 'use server' directive is what makes this safe to import into a client
component: the build extracts the function, gives the browser only a reference, and the body
never ships. Everything above — the database call, the validation schema — stays on the
server.
Wiring it to a form
'use client';
import { subscribe } from '@/app/actions';
import { useActionState } from 'react';
export function SignupForm() {
const [state, action, pending] = useActionState(subscribe, { ok: false });
return (
<form action={action}>
<input name="email" type="email" required />
<input name="name" required />
<button disabled={pending}>{pending ? 'Sending…' : 'Subscribe'}</button>
{state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}
</form>
);
}
The form works before hydration — no JavaScript required for the submit — and React upgrades it to a full fetch when the bundle loads. That progressive enhancement is free, which is the part that surprises people most.
What to keep in mind
- Actions are POST endpoints. Treat their arguments as untrusted input; validate on the server every time, exactly as if a stranger typed the URL.
- Return values must be serializable. Plain objects, arrays, primitives — not class instances or functions.
- Revalidation is part of the deal. After a write you usually call the framework's cache invalidation so the server components around the form re-render with fresh data.
Next: Context, and how to use it without re-rendering the world on every keystroke.