Form Without JavaScript: What Happens When action={createTodo} Is a Server Function
Disable JavaScript in your browser, open a signup page, and submit it. In most SPAs you get a silent no-op; in a Next.js form bound to a server action you get a created account. That is progressive enhancement in one demo, and it works because the form's action is a server function whose identifier is already embedded in the HTML.
// app/todos/NewTodo.tsx — no 'use client' needed for this shape
import { addTodo } from './actions';
export function NewTodo() {
return (
<form action={addTodo}>
<input name="title" placeholder="What needs doing?" required />
<input name="due" type="date" />
<button type="submit">Add</button>
</form>
);
}
With JavaScript, React intercepts the submit: it collects the fields into FormData, serializes a request carrying the action's build-generated ID, runs addTodo on the server, and streams back the re-rendered route — no page navigation, no full reload, no fetch written by you.
Without JavaScript, nothing is intercepted, so the browser does what browsers have done since 1995: POST the form to the current URL with the field values as application/x-www-form-urlencoded. Next.js has already placed hidden inputs in the markup ($ACTION_ID_…) identifying which action to run, so the server executes the same addTodo, and — because there is no client router to patch the DOM — the response is a full document containing the updated list. Both paths converge on one function and one re-render; the only difference is whether the result arrives as an RSC payload or as a new HTML document.
That convergence sets real constraints on how you write the form:
| Rule | Why |
|---|---|
Use plain name attributes; fields must be required/min etc. |
HTML validation is the only validation running with JS off |
Do not manage fields with useState for the no-JS path |
uncontrolled inputs are what the browser posts natively |
| Keep the success state server-rendered (revalidate and let the list re-render) | there is no client code to update local state |
| Return errors the template can render | with JS off, toast() does not exist — the message must be in the HTML |
Redirect on success (redirect('/login')) instead of returning a JSON status |
a document navigation needs a Location, not a payload |
The interaction with redirects is a sharp edge worth knowing: redirect() throws a special control-flow error, so call it after your mutation and do not wrap it in a try/catch that swallows it (catch and rethrow, or narrow the catch to the mutation itself). An action that catches everything will silently turn a redirect into a normal return, and the user stays on the form.
What you give up versus a fetch to an API route is also concrete: no streaming request body, no custom headers, no GET semantics, and the response is a rendered tree rather than data you manipulate. What you gain is smaller code, framework-owned revalidation, an identifier rather than a hand-maintained route, and a form that works before hydration. The next chapter is about the one thing the plain form cannot do by itself — telling the user what happened.
Showing the Result: useFormState, useActionState, and the Return Channel
The bare form works, but "worked" is invisible: the todo appears in the list only because the route re-rendered, and a failed action (name too long, duplicate slug, DB down) gives the user nothing to read. The action already returns a value — you need a way to hand that value back to the form's UI, and that is what the form-state hook does.
'use client';
import { useActionState } from 'react';
import { addTodo } from './actions';
type State = { message?: string; errors?: Record<string, string> } | null;
export function NewTodo() {
const [state, formAction, isPending] = useActionState<State, FormData>(
addTodo, // the server action
null, // initial state
);
return (
<form action={formAction}>
<input name="title" aria-invalid={!!state?.errors?.title} />
{state?.errors?.title && (
<p role="alert" className="text-red-600">{state.errors.title}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Adding…' : 'Add'}
</button>
</form>
);
}
The hook returns a three-tuple: [state, action, isPending]. The action it hands back is a bound version you put in <form action=...>, state is whatever your server action returned on the last invocation, and isPending covers the button label. On the server side the action's signature changes shape — its first argument is the previous state, the second is FormData:
'use server';
export async function addTodo(prev: State, formData: FormData): Promise<State> {
const title = String(formData.get('title') ?? '');
if (title.length < 3) return { errors: { title: 'Too short' } };
await db.todo.create({ data: { title } });
revalidatePath('/todos');
return { message: 'Added' };
}
Naming history matters here, because older code and newer docs disagree. What was useFormState (imported from react-dom, Next 14 era) is now useActionState (from react, React 19 / Next 15) — same tuple, same semantics, plus the pending flag folded in. If you are reading a tutorial that says useFormState, translate it mentally; the migration is a rename and an import change, and the return-value channel is identical.
Two design consequences follow. First, the return value is the only out-of-band channel — there is no imperative setState after the call, so structure returns as data: { errors: {...} } for field problems, { ok: true, id } for success, and let the template branch. Second, state is per form instance: two NewTodo forms each hold their own last result, and a key change (new todo id, route change) remounts the form and clears it — which is the standard trick for "reset the error message" when the action succeeded.
Note what the tuple's second element really is: formAction is not your raw import, it is a bound wrapper the hook returns, which knows how to dispatch to the server and pair the result with the current state. Passing the imported action directly into <form action={...}> still works (that is the previous chapter's version) — you just have no state to read afterwards. The two forms coexist in one codebase: bare action for simple submits, useActionState the moment the template needs to display a result.
Progressive enhancement still holds: with JS off, useActionState never hydrates, the browser posts natively, and the server's full-document response re-renders the form with the same state object passed through the action, so error messages appear in the HTML too. The hook enhances presentation (pending flag, no navigation) rather than adding capability. What it deliberately does not solve is what happens to the fields themselves — the reset semantics, covered next.
One common surprise when moving from an API-route architecture: after the action returns, you no longer write router.push('/success') or setSubmitting(false) — the framework owns both the pending window and the re-render. The code you delete in this migration (fetch, loading flag, refetch, refetch-after-error) is the argument for the pattern; the code you must now write carefully is the state shape returned by the action, since it is the only thing standing between "the server rejected this" and "the user understands why".
Reset, Retry, and Validation: What React Does to Your Fields After Submit
The user types a title, submits, sees "Added" — and the input is empty again without anyone writing setFormData(''). React resets an uncontrolled form automatically after its action completes successfully. That default is doing you a favor you should understand, because three bugs sit right next to it.
Bug one: the field that won't clear. If the input is controlled (value={title} onChange={...}), React's automatic reset sets the DOM value but your state still holds the old string, so the input snaps back to the typed text. The mismatch looks like "the reset is broken"; the actual fix is one of: make the input uncontrolled (preferred for forms bound to actions), or key the form after success so the component remounts with fresh state:
<form key={state?.id ?? 'idle'} action={formAction}> ... </form>
Bug two: the error that survives success. useActionState keeps the last result until the next submission, so a stale "Too short" can sit beside a now-valid list. Clear it where the source of truth changes — return a fresh state object from the action (you already do), or remount with key as above. Note the flip side, which is the useful behavior: an error stays visible until the user tries again, so a failed submit never silently erases its explanation.
Bug three: losing input on failure. Because React only clears fields after a successful action, a returned validation error leaves everything the user typed intact — which is exactly what you want when they must fix one field. If you find yourself re-populating the form from server data after an error, something in your flow is treating a normal validation failure like an exception; return the errors instead of throwing and the fields take care of themselves.
Validation itself has two layers, and the no-JS constraint decides how much each one matters:
layer 1: HTML attributes (required, minLength, type="email", pattern)
runs with JS off; fast, coarse, spoofable via curl
layer 2: schema parse inside the action (zod, valibot)
always runs; authoritative; returns structured field errors
Layer one is a courtesy — better UX, fewer pointless round trips. Layer two is the contract: any client can POST directly with $ACTION_ID filled in, so an action that trusts required alone is an unvalidated endpoint. The full schema pattern (parse FormData, map issue paths to field keys, return them for inline display) is Validating Action Input; the shape that makes it render is established right here.
Note how the two layers compose on a no-JS submit: the browser enforces required natively and never posts the empty field, but a curl that does post it gets the server-side error rendered back into the re-rendered form — the same error object, delivered through a document instead of a hook. That symmetry is the test of a well-designed action: ask whether each failure message still reaches the user with JavaScript disabled, and any that does not is a message you wired to client-only state.
Finally, the pending state: isPending disables the submit button, and disabling it is your defense against double submission — a second click before the first response would post twice and create two todos. Keep the button disabled, but do not rely on it as idempotency: networks retry, users mash Enter before hydration, and a well-written action makes duplicate creation impossible (a unique constraint, or a client-generated id in the form). The form-level guard improves UX; the data-level guarantee prevents the duplicate.
Quiz
1. JavaScript is disabled and the user submits a <form action={serverAction}>. What happens?
2. What does useActionState return, and what is its predecessor called?
3. Your controlled input keeps showing its old value after a successful action. Why?
4. An action returns { ok: false, errors } instead of throwing. What does the user experience?
Flashcards
Question
How does a form bound to a server action work without JavaScript?
Click to reveal answer
Answer
The action's generated ID is embedded as hidden inputs, so the browser natively POSTs the fields to the current URL; the server runs the same action and returns a full document with the updated route. Hence: HTML validation is the only client validation, and success/error output must be server-renderable.
Question
What does useActionState give you, and how does the action's signature change?
Click to reveal answer
Answer
Returns [state, formAction, isPending]: the last returned value, the bound action for <form action>, and a pending flag. The action becomes (prevState, formData) => Promise<State> — state is the only channel for errors/success, and it is the renamed successor of useFormState from react-dom.
Question
When does React clear an action-bound form's fields, and what breaks a controlled input?
Click to reveal answer
Answer
An uncontrolled form resets automatically after a successful action (failures keep the input for correction). A controlled input snaps back because React resets the DOM while your useState still holds the old value — fix by going uncontrolled or remounting via key.
Question
Two validation layers on an action-bound form, and which one is authoritative?
Click to reveal answer
Answer
HTML attributes (required, minLength) run without JS and are a UX courtesy only — spoofable by posting directly. The schema parse inside the action (zod on FormData) is authoritative: it always runs and maps issues to field-level errors for inline display.
Question
Why is disabling the submit button not enough to prevent double submission?
Click to reveal answer
Answer
isPending only guards after hydration: retries, Enter presses before JS loads, and direct POSTs all bypass it. Keep the button disabled for UX, but make the action itself idempotent — unique constraint or client-generated id — so duplicates are impossible at the data layer.
Revision Notes
Key Takeaways
- 1. A form bound to a server action submits natively without JS — the embedded action ID makes the POST work and forces server-rendered feedback
- 2. useActionState (successor of useFormState) is the only channel from action to template: [state, formAction, isPending]
- 3. React clears uncontrolled fields after a successful action; controlled inputs fight the reset, and errors intentionally persist until the next attempt
- 4. HTML validation is a courtesy — the schema parse inside the action is the contract, because anyone can post directly
- 5. Redirect after success must escape any try/catch, and pending state must be backed by idempotency to stop duplicates
Interview Tips
- • "How does this work with JS disabled?" — hidden $ACTION_ID, native urlencoded POST to the current URL, full-document response; then explain why messages must be server-renderable
- • "useFormState vs useActionState?" — rename plus import move (react-dom -> react) with the pending flag added; showing you know both eras reads as current experience
- • "Why did my input not clear?" — controlled vs uncontrolled and DOM-reset-vs-state; name the key-remount trick
- • "How do you prevent double submits?" — isPending for UX plus idempotency at the data layer; never claim the disabled button alone is the answer
Cheat Sheet
Progressive Forms with Actions
The form:
const [state, formAction, isPending] = useActionState(addTodo, null);
return (
<form key={state?.id ?? 'idle'} action={formAction}>
<input name="title" required aria-invalid={!!state?.errors?.title} />
{state?.errors?.title && <p role="alert">{state.errors.title}</p>}
<button disabled={isPending}>{isPending ? 'Adding…' : 'Add'}</button>
</form>
);
'use server';
export async function addTodo(prev: State, formData: FormData): Promise<State> {
const parsed = TodoSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { errors: fieldErrors(parsed.error) };
await db.todo.create({ data: parsed.data });
revalidatePath('/todos');
return { ok: true, id: newId };
}
No-JS path: hidden $ACTION_ID input -> browser POSTs urlencoded fields -> same action -> full document. Success/error must therefore be server-renderable; use redirect() for navigation (it throws — don't swallow it in a broad catch).
Reset semantics:
| Situation | Fields |
|---|---|
| success (uncontrolled) | cleared automatically |
| returned validation error | preserved for correction |
| controlled input | DOM reset but state wins -> key remount |
| error returned by action | stays until next submission |
Validation: HTML attributes = courtesy (works with JS off, spoofable) · schema inside action = authority (always runs, returns field errors).
Double submit: isPending disables the button post-hydration only — enforce idempotency in the action/data layer.