Skip to content
Chapter 44Lesson 2

Wiring the action prop

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

Last lesson you built a form whose inputs are uncontrolled, each carrying a name, and whose values round-trip through FormData for free. In the previous chapter you built the createInvoice Server Action that takes exactly that FormData. Both halves are finished, but the connection between them is missing, and it turns out to be 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 deliberately incomplete so you don’t mistake the bare wiring for the finished pattern. The question underneath is this: what’s the minimum that connects the two, and why isn’t the answer fetch?

Here is the entire connection, before any explanation of how it works. The first thing to absorb 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>
);
};

That’s it. In real code every input still pairs with a <label>; they’re dropped here only so the one new thing stands alone. That one new thing is action={createInvoice}: you assigned the action’s function reference to the form’s action prop. That single 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, and no request body built by hand. On submit, React reads the form’s named inputs, builds a FormData from them, and calls createInvoice(formData) for you. The values arrive in that FormData because the inputs are uncontrolled: the DOM holds each one’s live value, and the DOM is exactly what the browser serializes on a submit. The work you did in the last lesson is what makes this line so short.

The last lesson named one requirement and deferred it, and here’s where it pays off: 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, exactly as you left it. The bridge is the import. When the client imports createInvoice, the compiler doesn’t ship the function body into the browser bundle; it rewrites that import to an opaque action ID . Under the hood the submit becomes an HTTP POST carrying that ID and the FormData. You saw this seam from the server side in the previous chapter; here it runs to completion from the client. How the IDs rotate and how it’s secured is the subject of a later chapter. For now, trust the bridge and keep your eye on the prop.

Notice the contract that makes it all 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. That’s true, but a lot happens between the click and the call, and seeing those steps is what replaces the fetch mental model with the platform’s. In practice the handshake is invisible and instantaneous, so let’s slow it down and step through it 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">. This fires 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: any required, type, or pattern checks. An invalid field blocks the submit, and the action never runs. These are the cheap client checks, covered in a later lesson of this chapter. Because they 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→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 you built in the previous chapter, parse → authorize → mutate → revalidate → return a Result. It’s one collapsed step here, and 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 triggers the affected Server Components to refetch, so a list elsewhere reflects the new row. In this lesson the form ignores the returned Result; reading it is the next lesson. That’s the gap we’ll close.

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. The next section is all about this one beat.

Read the strip by owner. Two beats belong to the browser, the click and the constraint check, both 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. And one belongs to your action on the server: parse, mutate, return. Hold that split, because it’s the takeaway. You write neither the POST nor the FormData construction. React owns the intercept, the serialize, the call, and the reset; your action owns the parse, the mutate, and the return. The seam in the middle is the network, and the platform crosses it for you.

The submit is an ordered process, and that order is the thing to internalize, so put it 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 roughly 2018 and 2023, the wiring you just saw is missing everything your hands expect. Where’s the handler? Where’s the preventDefault? Where’s the fetch? That reflex is worth examining directly, because the action prop doesn’t just shorten the old approach, it deletes most of it. This section names why the platform path wins. Here are the same two fields, the old way and the new.

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 here is plumbing React 19 deletes: the 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 concrete counts, and each one ties to something you already know or will meet soon.

It’s shorter. No handler, no preventDefault, no per-field state, no request body assembled by hand. You saw the line count.

It runs without JavaScript. This is the one that surprises people. If the JS bundle hasn’t loaded yet, a <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 even fire until the bundle is parsed and the handler is attached: no JS, no submit. This property is called progressive enhancement, it’s the payoff of the closing lesson of this chapter, and it falls out of the action prop for free.

It reuses the framework’s security and serialization. Next.js generates origin checks and tokens around every action invocation, so you’re not rolling your own cross-site-request protection or wire format. The fetch-to-an-API-route path puts all of that back on you. The full security story is a later chapter; here it’s enough to know the framework owns the boundary.

It composes with the hooks coming next. The next three lessons cover useActionState for pending state and errors, useFormStatus for the submit button, and useOptimistic for instant feedback, and all three 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, and naming the boundary keeps you from over-applying either reflex. The fetch path is correct precisely 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 isn’t “which is newer,” it’s who owns the endpoint. For a mutation of your own data, the action prop is the default. For a call out to 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”

The last beat in the lifecycle is the one that catches people, so it gets its own section. When an uncontrolled <form action={fn}> submits and the action resolves successfully, meaning it returns without throwing, React resets the form’s inputs to their defaultValues. The fields you just submitted go blank, or back to their seed value. React runs the reset after the commit of the render that follows the action, a timing detail that matters in a moment.

Before you file that under “surprising behavior to disable,” look at what it’s actually for, because for the most common form in any app it is exactly right. Think about the create flow: the user creates invoice INV-104, the action succeeds, and what they want next is a blank form to start INV-105. The auto-reset hands them that blank form without a line of code. For “create a thing, then create another,” the reset is a feature, which is why it’s the default.

Where it’s wrong is the edit form: the profile page, the settings panel, anything that saves and stays put. The user edits their display name, hits save, and the auto-reset wipes their typed value, snapping the field back to whatever defaultValue it rendered with. That’s the wrong experience: after a successful save they should see their saved values sitting in the form, not a reset. There are two senior reaches for this case.

The canonical one pairs the form with useActionState and feeds the action’s returned data back in as defaultValue. The saved entity comes back inside the Result, it becomes the next render’s defaultValue, and because React runs the reset after that render commits, the reset lands on the saved values rather than the originals. That post-commit timing is the whole reason this works. You haven’t met useActionState yet, it’s 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 reach is the explicit escape hatch: requestFormReset from react-dom. It lets you control the reset yourself, calling it when you want the form cleared instead of letting React do it on success. It affects uncontrolled inputs only. You’ll reach for it rarely; it’s named here so you know the manual lever exists, but the default is to let React reset and to use the useActionState pattern above for the edit case.

One more thing about the reset, included here because it pairs with the next lesson: it fires only on success. A failed action, one that returns ok: false or throws, does not reset the form. So when the user’s submit bounces off a validation error, their typed values survive in the uncontrolled inputs, and they can fix the one flagged field and resubmit without retyping everything. That’s the correct behavior, and it’s why the next lesson’s field-error rendering works: the values are still there to correct.

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. The platform has a native answer for this, and React exposes it directly.

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 form’s FormData once, then dispatches it to whichever button was clicked. Click Save draft (or hit Enter) and the form’s primary action, saveDraft, runs. Click Publish and that button’s formAction wins over the form’s action, so publish runs instead with the same FormData. The form’s action is the default, the destination for the default submit button and the Enter key, and each formAction is a per-button override.

Each of those actions is its own Server Action, and in the full pattern each pairs with its own handling of the returned Result, which is again the next lesson’s job. What’s worth noticing now is where the behavior comes from: formAction is the native HTML formaction attribute, and React just camelCases the name. The dispatch-to-the-clicked-button logic is the browser’s; React’s only contribution is letting the value be a function instead of a URL. The platform does the work, the same theme running through this whole chapter.

The same rule from the action prop applies here, so don’t trip on it: pass the function reference, formAction={publish}, never an arrow that calls it. The next section explains why.

There’s a second Form in this stack, and the naming collision trips people, so let’s settle when to reach for each. Next.js ships its own <Form> component, imported from next/form, that extends the native <form> with three things: prefetching of the destination route’s loading UI, client-side navigation on submit, and progressive enhancement. It sounds like a strict upgrade. It isn’t, and the reason comes down to a single question.

The hinge is this: <Form>’s headline feature, the prefetch , only applies when the form’s action is a string URL. Picture a search form that GETs to /search?q=.... Because the destination URL is known up front, <Form> can prefetch that route’s loading UI as the form scrolls into view, warming the navigation so the results feel instant when the user submits. That’s a real win for search and filter forms.

Now picture your invoice form, whose action is a function, a Server Action. Where does that submit land? You don’t know until the action runs: it might redirect to the new invoice, or return a Result and stay put. Because the destination is unknown until run time, there is nothing for <Form> to prefetch. For a mutation form, <Form> and native <form> are equivalent: same submit, same progressive enhancement, no prefetch either way.

So the 2026 reflex is clean. Use the native <form> for mutations, where the action is a function, and <Form> for search and filter, where the action is a string URL. This whole chapter is about mutations, so the default here is the native <form> you’ve been writing. Run the deciding question yourself.

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

The search-form side of that fork, covering URL state, <Form>, and prefetching a results route, is a whole topic that a later chapter on list views owns. Here it’s named only as the boundary, so you know which fork you’re on and why.

One mistake with the action prop breaks everything silently, and it’s the single most common bug at this API, so it gets its own section. The rule is one line: pass the action’s function reference directly.

<form action={createInvoice}>

Never wrap it in an arrow that calls it:

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

The arrow version looks reasonable, and that’s exactly what makes it costly. Here’s why it breaks. 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 no longer sees a form action; it sees an ordinary click-style function. Two things break at once. First, the FormData argument is gone: where would the formData inside that arrow even come from? Nothing is passing it. Second, the build-time rewrite is lost, so the progressive-enhancement fallback silently stops working. The form breaks without JavaScript and keeps working with it, which is the worst possible failure mode because every dev test passes.

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

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

The temptation to reach for the arrow usually shows up when you need to pass an extra argument alongside the FormData: a fixed invoice ID, say, that the action needs but the form doesn’t carry. The arrow is the wrong tool for that. The right one is action.bind(null, id), which produces a new function reference with the ID pre-applied. React still recognizes it as a form action, still appends the FormData, and still keeps progressive enhancement intact. It’s named now so the right reflex fires when the arrow tempts you; you’ll put it to real use where the project needs it.

Be precise about what you’ve built, because the gaps are the point. Your form now submits to the Server Action, resets on success, and falls back to a plain POST without JavaScript. Those three came from one prop and the uncontrolled inputs underneath it.

Here’s what it still can’t do. It can’t tell the user it’s saving, because there’s no pending state, so a slow action just looks frozen. And it can’t show what went wrong: the action returns a Result with field errors and a message, and right now the form discards that Result. The wiring is correct; the form is intentionally incomplete.

The hook that closes both gaps is useActionState. It captures the action’s pending state, so you can render “Saving…” and disable the button, and it captures the returned Result, so you can render the banner and the inline field errors. That’s the next lesson, and it’s where the form you wired today learns to talk back.