C.W.K.
Stream
Lesson 01 of 06 · published

<form action={fn}> — React 19's Form Model

~13 min · form-action, react-19, forms

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
React 19 overloaded the HTML form's action attribute. Pass a string and the browser handles submission as it has since 1993. Pass a function and React owns the lifecycle.

The two modes

  • <form action="/save" method="POST"> — full-page browser submit. Works without JS. The mode the web has always had.
  • <form action={saveFn}> — React intercepts submission, calls saveFn(formData), manages the pending state, prevents the default browser submit. JS required. The mode React 19 added.

The function mode is what unlocks the Actions story. The hooks in the next five lessons (useActionState, useFormStatus, useOptimistic) all assume the form is in function-action mode.

FormData as the input

Your action function receives a FormData object — the standard browser type for serialized form fields. Read fields with formData.get('name'); check files with formData.get('avatar') instanceof File. No JSON parsing, no manual field collection.

Reset on submit

After an Action runs successfully, React clears the form's uncontrolled inputs (resets the form). If you want to keep values (controlled inputs, multi-step forms), manage the values yourself with useState.

What happens to native form behaviors

HTML validations still fire first (required, type=email, minlength). If the user's input fails native validation, the Action doesn't run. Inside the Action you get already-validated input — though you should still validate server-side for security (lesson 6 on Zod).

The form is the model, the function is the handler. React 19's Action model leans into the native form contract — name your inputs, let the browser collect them, hand React a function to do the actual work. The result is shorter than the old onSubmit + e.preventDefault + collect fields + fetch + setState dance.

Code

The simplest Action — function passed to <form action>·tsx
async function saveDraft(formData: FormData) {
  const title = formData.get("title") as string;
  const body = formData.get("body") as string;
  await fetch("/api/drafts", {
    method: "POST",
    body: JSON.stringify({ title, body }),
  });
}

function DraftForm() {
  return (
    <form action={saveDraft} className="space-y-4">
      <input name="title" required className="w-full px-3 py-2 border" />
      <textarea name="body" required className="w-full px-3 py-2 border" />
      <button type="submit" className="px-4 py-2 bg-brand text-bg rounded">
        Save draft
      </button>
    </form>
  );
}

// No e.preventDefault, no useState for title/body, no fetch in an onSubmit.
// React runs saveDraft with the FormData on submit.
Acting on a single button instead of the form·tsx
// A button with its own action — overrides the form's action for that submitter.
async function deleteDraft() {
  await fetch("/api/drafts/current", { method: "DELETE" });
}

function DraftForm2() {
  return (
    <form action={saveDraft}>
      <input name="title" required />
      <textarea name="body" required />
      <div className="flex gap-2">
        <button type="submit">Save</button>
        <button type="submit" formAction={deleteDraft} className="text-danger">
          Delete
        </button>
      </div>
    </form>
  );
}

// Clicking 'Delete' calls deleteDraft instead of saveDraft.
// Same form, two buttons, two actions. Native HTML supports this; React 19 honors it.

External links

Exercise

Build a <NewMessageForm> that takes a text input and a submit button. Wire <form action={sendMessage}> where sendMessage is an async function that POSTs to a fake endpoint and logs the result. Verify the form clears after submit (uncontrolled input pattern). Then add a second button with formAction={saveDraft} that saves without posting. Confirm clicking each button runs the right function.
Hint
Use uncontrolled inputs (no value={...} prop) to see React 19's reset behavior. If you control with useState, the displayed value stays — the action just runs, but you have to reset state yourself.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.