LearnFrenzy
Sign in Take a free test

Forms in React · Lesson 15 of 31

React 19 Form Actions

6 min read Updated Nov 8, 2026 Saurabh Samir

React 19 added actions: functions that run when a form is submitted, possibly asynchronously. React tracks their pending state, collects their result and resets the form for you. Together with three new hooks – useActionState, useFormStatus and useOptimistic – they remove most of the boilerplate around submitting data. In Next.js the same API powers Server Actions.

In this lesson

  • Passing a function to <form action>
  • useActionState for results, errors and pending state
  • useFormStatus for submit buttons
  • useOptimistic for instant UI updates

A form action

import { useState } from "react";

export default function App() {
  const [names, setNames] = useState(["Asha"]);

  function addName(formData) {                     // receives FormData, no event, no preventDefault
    setNames((n) => [...n, formData.get("name")]);
  }

  return (
    <form action={addName}>
      <label>Name <input name="name" /></label>
      <button>Add</button>
      <p>Attendees: {names.join(", ")}</p>
    </form>
  );
}

Result

Name [] [Add]
Attendees: Asha

› type “Ravi” into Name
Name [Ravi] [Add]
Attendees: Asha

› click “Add”
Name [] [Add]
Attendees: Asha, Ravi

When you pass a function to action, React calls it with the form's FormData, prevents the page reload, and resets the form's uncontrolled fields after the action finishes – notice the input is empty again.

useActionState: results, errors and pending

import { useActionState } from "react";

// pretend API call
async function subscribe(previousState, formData) {
  await new Promise((r) => setTimeout(r, 300));
  const email = String(formData.get("email") ?? "").trim();
  if (!email.includes("@")) return { error: "Enter a valid email", email };   // keep the typed value
  return { message: `Subscribed ${email}` };
}

export default function App() {
  const [state, formAction, isPending] = useActionState(subscribe, {});   // (action, initial state)

  return (
    <form action={formAction}>
      <label>Email <input name="email" defaultValue={state.email} key={state.email} /></label>
      <button disabled={isPending}>{isPending ? "Subscribing…" : "Subscribe"}</button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.message && <p>✔ {state.message}</p>}
    </form>
  );
}

Result

Email [] [Subscribe]

› type “asha” into Email
Email [asha] [Subscribe]

› click “Subscribe”
Email [asha] [Subscribing… (disabled)]

› wait 350ms
Email [asha] [Subscribe]
Enter a valid email

› clear Email and type “asha@example.com”
Email [asha@example.com] [Subscribe]
Enter a valid email

› click “Subscribe”
Email [asha@example.com] [Subscribing… (disabled)]
Enter a valid email

› wait 350ms
Email [] [Subscribe]
✔ Subscribed asha@example.com
  • The action receives the previous state and the FormData; whatever it returns becomes the new state.
  • isPending is true while the action runs – no manual setLoading.
  • Because the form resets after an action, the example returns the typed email on error and uses it as defaultValue (the key makes React apply the new default).

useFormStatus: a reusable submit button

import { useFormStatus } from "react-dom";
import { useActionState } from "react";

function SubmitButton({ children }) {
  const { pending } = useFormStatus();             // status of the parent <form>
  return <button disabled={pending}>{pending ? "Please wait…" : children}</button>;
}

async function saveProfile(prev, formData) {
  await new Promise((r) => setTimeout(r, 200));
  return `Saved bio (${String(formData.get("bio")).length} characters)`;
}

export default function App() {
  const [message, formAction] = useActionState(saveProfile, "");
  return (
    <form action={formAction}>
      <textarea name="bio" aria-label="Bio" defaultValue="Frontend developer" />
      <SubmitButton>Save profile</SubmitButton>
      {message && <p>{message}</p>}
    </form>
  );
}

Result

[Frontend developer] [Save profile]

› click “Save profile”
[Frontend developer] [Please wait… (disabled)]

› wait 250ms
[Frontend developer] [Save profile]
Saved bio (18 characters)

useFormStatus must be called from a component rendered inside the form – that's why the button is its own component. Every form in your app can now reuse one SubmitButton.

useOptimistic: update the UI before the server answers

import { useOptimistic, useState } from "react";

async function sendToServer(text) {
  await new Promise((r) => setTimeout(r, 400));
  return text;
}

export default function App() {
  const [messages, setMessages] = useState(["Hi! Ready for the React quiz?"]);
  const [optimistic, addOptimistic] = useOptimistic(messages, (current, text) => [...current, text + " (sending…)"]);

  async function send(formData) {
    const text = formData.get("message");
    addOptimistic(text);                            // shows immediately
    const saved = await sendToServer(text);
    setMessages((m) => [...m, saved]);              // real state replaces the optimistic one
  }

  return (
    <form action={send}>
      <ul>{optimistic.map((m, i) => <li key={i}>{m}</li>)}</ul>
      <input name="message" aria-label="Message" />
      <button>Send</button>
    </form>
  );
}

Result

• Hi! Ready for the React quiz?
[] [Send]

› type “Yes, let's go!” into Message
• Hi! Ready for the React quiz?
[Yes, let's go!] [Send]

› click “Send”
• Hi! Ready for the React quiz?
• Yes, let's go! (sending…)
[Yes, let's go!] [Send]

› wait 450ms
• Hi! Ready for the React quiz?
• Yes, let's go!
[] [Send]

While the action runs, React shows the optimistic list. When it finishes, the optimistic value is dropped and the real messages state is shown. If the request fails, the optimistic item simply disappears – like a chat app showing "not sent".

Controlled forms or actions?

  • Actions – submit-and-save forms: sign-ups, comments, settings. Less state, built-in pending, and they work with Next.js Server Actions.
  • Controlled inputs – when the UI must react to every keystroke: live search, formatting, dependent fields.
  • You can mix them: a controlled field inside a form that uses an action.

Practice

Try it yourself

  1. Build a coupon form with useActionState: "LEARN20" gives "20% off applied", anything else shows "Invalid coupon".

    Show solution
    import { useActionState } from "react";
    
    async function applyCoupon(prev, formData) {
      await new Promise((r) => setTimeout(r, 150));
      const code = String(formData.get("code")).trim().toUpperCase();
      return code === "LEARN20" ? "20% off applied" : "Invalid coupon";
    }
    
    export default function App() {
      const [result, formAction, isPending] = useActionState(applyCoupon, "");
      return (
        <form action={formAction}>
          <label>Coupon <input name="code" /></label>
          <button disabled={isPending}>Apply</button>
          {result && <p>{result}</p>}
        </form>
      );
    }

    Result

    Coupon [] [Apply]
    
    › type “save10” into Coupon
    Coupon [save10] [Apply]
    
    › click “Apply”
    Coupon [save10] [Apply (disabled)]
    
    › wait 200ms
    Coupon [] [Apply]
    Invalid coupon
    
    › type “learn20” into Coupon
    Coupon [learn20] [Apply]
    Invalid coupon
    
    › click “Apply”
    Coupon [learn20] [Apply (disabled)]
    Invalid coupon
    
    › wait 200ms
    Coupon [] [Apply]
    20% off applied

Quick check

What does a form action function receive?

The form's FormData (and with useActionState, the previous state first).

Where must useFormStatus be called?

In a component rendered inside the form.

What happens to an optimistic value when the action finishes?

It's discarded and the real state is shown.

Summary

  • <form action={fn}> calls fn with FormData, without a reload, and resets the form afterwards.
  • useActionState returns [state, formAction, isPending]; useFormStatus gives a nested button the pending flag.
  • useOptimistic shows the expected result instantly while the async action runs.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular