Skip to content
Chapter 44Lesson 2

Wiring the action prop

The React 19 action prop, the one line that connects a native HTML form to a Server Action.

Last lesson you built a form with uncontrolled inputs, each carrying a name, whose values round-trip through FormData. In the previous chapter you built the createInvoice Server Action that takes that FormData. Both halves are finished; the connection between them is a single prop. By the end of this lesson your form will submit to the action and clear itself for the next entry. It won’t yet tell the user it’s saving or show what went wrong, and we’ll leave it incomplete on purpose so you don’t mistake the bare wiring for the finished pattern. The question underneath: what’s the minimum that connects the two, and why isn’t the answer fetch?

The action prop connects the form to the Server Action

Section titled “The action prop connects the form to the Server Action”

Here is the entire connection. The first thing to notice is how little there is.

app/invoices/new-invoice-form.tsx
'use client';
import { createInvoice } from './actions';
export const NewInvoiceForm = () => {
return (
<form action={createInvoice}>
<input name="customer" type="text" />
<input name="total" type="number" />
<button type="submit">Create invoice</button>
</form>
);
};

In real code every input still pairs with a <label>; they’re dropped here so the one new thing stands alone. That new thing is action={createInvoice}: you assigned the action’s function reference to the form’s action prop, and that assignment is the whole bridge.

Notice what you did not write. There’s no onSubmit, no event.preventDefault(), no fetch, no JSON.stringify, no Content-Type header, no hand-built request body. On submit, React reads the form’s named inputs, builds a FormData from them, and calls createInvoice(formData) for you. The values arrive because the inputs are uncontrolled: the DOM holds each one’s live value, and the DOM is exactly what the browser serializes on submit. Last lesson’s work is what makes this line so short.

This form lives in a Client Component, so the 'use client' directive at the top is load-bearing. The action prop only wires up React’s submit interception inside a Client Component. A Server Component can pass an action down as a prop, but the <form> that consumes it renders client-side.

The action itself does not move to the client. createInvoice stays a Server Action, with 'use server' at the top of its own file. The bridge is the import: when the client imports createInvoice, the compiler doesn’t ship the function body into the browser bundle but rewrites the import to an opaque action ID . The submit becomes an HTTP POST carrying that ID and the FormData. How the IDs rotate and how this is secured comes in a later chapter; for now, trust the bridge and keep your eye on the prop.

One contract makes it fit with no glue: the input names, the FormData keys, and the schema keys are one set of strings. That’s why the action can open with Object.fromEntries(formData) and parse the result without the form knowing anything about the schema. Both were written against the same names.

action={createInvoice} reads like you handed React a function and it calls it. It does, but a lot happens between the click and the call. The handshake is invisible and instant, so here it is one beat at a time.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

The user clicks the <button type="submit">, firing the form’s native submit, a real platform event rather than a React-only synthetic one.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

The browser runs constraint validation first: the required, type, and pattern checks. An invalid field blocks the submit and the action never runs. Because these checks run before the action, action and HTML validation compose for free.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

React serializes the form’s named fields into a FormData, the same name-to-value multimap from the last lesson. The uncontrolled inputs’ DOM values go straight in.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

React calls the action with that FormData. Under the hood this is an HTTP POST carrying the FormData body and the action’s opaque ID, not a JSON fetch you wrote.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

The action runs on the server: the five seams from the previous chapter, parse → authorize → mutate → revalidate → return a Result. One collapsed step here, since you already know what’s inside.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

The Result returns, and the revalidatePath the action called makes the affected Server Components refetch, so a list elsewhere shows the new row. For now the form ignores the returned Result; reading it comes next lesson.

Browser React (client) Server
click validate serialize call (POST) Server Action (ch. 43) Result + revalidate reset

On success, React resets the uncontrolled form to its defaultValues: a blank form, ready for the next invoice.

Read the strip by owner, and that split is the takeaway. Two beats belong to the browser: the click and the constraint check, native platform behavior that predates React. Three belong to React on the client: serializing the fields, making the call, and resetting the form on success. One belongs to your action on the server: parse, mutate, return. You write neither the POST nor the FormData construction; React owns the intercept, serialize, call, and reset, your action owns the parse, mutate, and return. The seam between them is the network, and the platform crosses it for you.

Now put the order back together yourself.

A user just submitted a `<form action={createInvoice}>`. Put the steps in the order they happen. Drag the items into the correct order, then press Check.

The user clicks the submit button, firing the form’s native submit
The browser runs constraint validation on the fields
React serializes the named fields into a FormData
React calls the action with that FormData as an HTTP POST
The Server Action parses, mutates, and returns a Result
revalidatePath refetches the affected Server Components
On success, React resets the form to its defaultValues

If you learned React between 2018 and 2023, this wiring is missing everything your hands expect: no handler, no preventDefault, no fetch. The action prop doesn’t shorten the old approach, it deletes most of it. Here are the same two fields both ways.

const [customer, setCustomer] = useState('');
const [total, setTotal] = useState('');
async function handleSubmit(e) {
e.preventDefault();
await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer, total }),
});
}
return (
<form onSubmit={handleSubmit}>
<input value={customer} onChange={(e) => setCustomer(e.target.value)} />
<input value={total} onChange={(e) => setTotal(e.target.value)} />
<button type="submit">Create invoice</button>
</form>
);

Every line is plumbing React 19 deletes: per-field state, the handler, the preventDefault, the hand-built request body.

The shorter code is the least of it. The action prop wins on four counts.

It’s shorter. No handler, no preventDefault, no per-field state, no request body assembled by hand.

It runs without JavaScript. If the bundle hasn’t loaded yet, <form action={createInvoice}> falls back to a plain POST to the action’s URL, so a user on a flaky connection who submits early still gets their invoice created. The fetch path can’t fire until the bundle is parsed and the handler attached: no JS, no submit. This is progressive enhancement, and you get it for free.

It reuses the framework’s security and serialization. Next.js generates origin checks, tokens, and the wire format around every action, so you don’t roll your own. The fetch-to-an-API-route path puts all of that back on you.

It composes with the hooks coming next. useActionState, useFormStatus, and useOptimistic all plug into the action prop natively. An onSubmit+fetch form is cut off from every one of them.

So is onSubmit+fetch ever right? Yes, when the submit target isn’t one of your app’s own Server Actions: a third-party SDK that wants a JSON body, an analytics beacon, an endpoint on someone else’s domain. The deciding question is who owns the endpoint. To mutate your own data, reach for the action prop; to call code you don’t control, reach for fetch.

The automatic reset on success, and when it’s wrong

Section titled “The automatic reset on success, and when it’s wrong”

When an uncontrolled <form action={fn}> submits and the action resolves successfully (returns without throwing), React resets the inputs to their defaultValues: the fields go blank, or back to their seed value. The reset runs after the commit of the render that follows the action, a timing detail that matters below.

For the most common form in any app, that is exactly right. Picture a create flow: the user saves invoice INV-104, the action succeeds, and they want a blank form to start INV-105. The reset hands them that blank form for free, which is why it’s the default.

It’s wrong for the edit form: a profile page, a settings panel, anything that saves and stays put. The user edits their display name, hits save, and the reset wipes the typed value back to the original defaultValue. After a successful save they should see their saved values in the form. Two fixes handle this.

The canonical one pairs the form with useActionState and feeds the action’s returned data back in as defaultValue. The saved entity comes back in the result and becomes the next render’s defaultValue, so when the reset fires after that render commits, it lands on the saved values, not the originals. useActionState is the next lesson, so this is a forward pointer, not something to wire today. The shape it lands at:

// next lesson wires up `state`; the point here is where its data lands
<input name="customer" defaultValue={state.data?.customer} />

The second fix is the explicit escape hatch: requestFormReset from react-dom, which clears uncontrolled inputs when you call it instead of on success. You’ll rarely need it; the default is to let React reset and use the useActionState pattern for edit forms.

The reset fires only on success. A failed action, one that returns ok: false or throws, leaves the form alone, so the user’s typed values survive in the uncontrolled inputs and they can fix the one flagged field and resubmit without retyping. That is what makes the next lesson’s field-error rendering work.

Sometimes one form’s fields drive more than one mutation: an invoice editor with Save draft and Publish, or a row with Save and Delete. Same inputs, same FormData, but the button you click decides which action runs.

app/invoices/edit-invoice-form.tsx
<form action={saveDraft}>
<input name="customer" type="text" defaultValue={invoice.customer} />
<input name="total" type="number" defaultValue={invoice.total} />
<button type="submit">Save draft</button>
<button type="submit" formAction={publish}>Publish</button>
</form>

The browser collects the FormData once, then dispatches it to whichever button was clicked. The form’s action is the default: click Save draft or hit Enter and saveDraft runs. A button’s formAction overrides that default: click Publish and publish runs instead, with the same FormData.

formAction is the native HTML formaction attribute, camelCased. The dispatch logic is the browser’s; React’s only addition is letting the value be a function instead of a URL.

The same rule as the action prop applies: pass the function reference, formAction={publish}, never an arrow that calls it. The last section of this lesson explains why.

This stack has a second Form, and the naming collision is worth settling. Next.js ships a <Form> component from next/form that extends native <form> with prefetching of the destination route, client-side navigation on submit, and progressive enhancement. That reads like a strict upgrade, but one detail decides it: prefetching only works when action is a string URL.

A search form that GETs to /search?q=... has a known destination, so <Form> can prefetch that route’s loading UI as the form scrolls into view. A mutation form whose action is a Server Action does not: the action might redirect or stay put, and you don’t know which until it runs, so there’s nothing to prefetch. For mutations, <Form> and native <form> are equivalent, so reach for native <form>.

This chapter is entirely mutations, so the native <form> you’ve been writing is the default. Run the deciding question yourself.

Native <form> or Next's <Form>?

The single most common bug at this API is wrapping the action in an arrow. Pass the action’s function reference directly instead.

When you pass the bare reference, React recognizes the prop as a form action and takes over the lifecycle you stepped through earlier: it serializes the fields, supplies the FormData argument, and applies the build-time rewrite that makes the no-JS POST fallback work. Wrap it in an arrow and React sees an ordinary click-style function instead, and two things break. The FormData argument is gone, since nothing passes the formData that arrow refers to. And the build-time rewrite is lost, so the form keeps working with JavaScript but silently fails without it, the worst failure mode because every dev test still passes.

<form action={() => createInvoice(formData)}>

Works with JS, breaks without it, and there’s no formData for that arrow to pass.

The arrow tempts you when the action needs an extra argument the form doesn’t carry, like a fixed invoice ID. The right tool for that is action.bind(null, id): it returns a new function reference with the ID pre-applied, which React still recognizes as a form action, still gets the appended FormData, and still keeps progressive enhancement intact.

Your form now submits to the Server Action, resets on success, and falls back to a plain POST without JavaScript, all from one prop on top of uncontrolled inputs.

Two things it still can’t do. It can’t tell the user it’s saving: with no pending state, a slow action just looks frozen. And it can’t report failures: the action returns a Result with a message and field errors, but the form discards it. The wiring is correct; the form is intentionally incomplete.

useActionState, the next lesson, closes both gaps. It exposes the pending state, so you can show “Saving…” and disable the button, and it keeps the returned Result, so you can render the error banner and inline field errors.