Gerson

Gerson

Passionate developer specializing in web development, cloud architecture, and system design.

TypeScriptReactNext.jsPythonFastAPISQLNode.jsAWS

React 19 Actions: The Complete Guide to Modern Form Handling

React 19 fundamentally changed how we handle forms with Actions, useActionState, useOptimistic, and the form action prop. This guide covers every new pattern with practical TypeScript examples.

React logo on a code editor representing modern React development

React 19 shipped with a set of features that fundamentally changed how we build forms and handle server interactions. If you're still writing onSubmit handlers with useState for loading states and try/catch for error handling, React 19 has a better way.

The key additions are Actions (async functions in transitions), the form action prop, useActionState, useFormStatus, and useOptimistic. Together, they eliminate most of the boilerplate around form submissions and data mutations.

What Are Actions?

In React 19, functions that use async transitions are called "Actions." The core idea is simple: you pass an async function to useTransition, and React automatically manages the pending state, error handling, and optimistic updates for you.

Before: Manual state management

function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);
    setError(null);
    try {
      await updateNameOnServer(name);
    } catch (err) {
      setError((err as Error).message);
    } finally {
      setIsPending(false);
    }
  };

  return (/* ... */);
}

After: Using Actions with useTransition

function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const result = await updateNameOnServer(name);
      if (result.error) {
        setError(result.error);
      }
    });
  };

  return (/* ... */);
}

The async transition automatically sets isPending to true at the start of the request and resets it when the final state update is committed. No manual setIsPending calls needed.

The Form Action Prop

React 19 added support for passing functions directly to the action prop of <form> elements. This is the biggest ergonomic improvement — you can now write forms that work with progressive enhancement built in:

Form with action prop

function CreatePost() {
  async function createPost(formData: FormData) {
    "use server";
    const title = formData.get("title") as string;
    const body = formData.get("body") as string;
    await db.post.create({ data: { title, body } });
    redirect("/posts");
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="body" placeholder="Write your post..." />
      <button type="submit">Publish</button>
    </form>
  );
}

When you pass a function to action, React wraps it in an Action automatically. The form is submitted as a transition, the pending state is managed for you, and the form resets automatically after a successful submission.

useActionState

For forms that need to display results or errors from the server, useActionState is the right tool. It takes an action function and returns a wrapped action along with the current state and pending flag:

useActionState example

import { useActionState } from "react";

interface FormState {
  message: string;
  errors?: { email?: string[] };
}

function SignupForm() {
  const [state, formAction, isPending] = useActionState(
    async (prevState: FormState, formData: FormData): Promise<FormState> => {
      const email = formData.get("email") as string;

      if (!email.includes("@")) {
        return { message: "", errors: { email: ["Invalid email address"] } };
      }

      await createAccount(email);
      return { message: "Account created successfully!" };
    },
    { message: "" }
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state.errors?.email && (
        <p className="text-red-500">{state.errors.email[0]}</p>
      )}
      <button disabled={isPending}>
        {isPending ? "Creating..." : "Sign Up"}
      </button>
      {state.message && <p className="text-green-500">{state.message}</p>}
    </form>
  );
}

useOptimistic

Optimistic updates make your UI feel instant by showing the expected result before the server confirms it. React 19 makes this a first-class pattern:

Optimistic updates with useOptimistic

import { useOptimistic } from "react";

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state: Todo[], newTodo: Todo) => [...state, newTodo]
  );

  async function addTodo(formData: FormData) {
    const text = formData.get("text") as string;
    const tempTodo = { id: "temp-" + Date.now(), text, completed: false };

    addOptimisticTodo(tempTodo);
    await createTodoOnServer(text);
  }

  return (
    <div>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
      <form action={addTodo}>
        <input name="text" placeholder="Add a todo..." />
        <button type="submit">Add</button>
      </form>
    </div>
  );
}

useFormStatus

Sometimes you need pending state inside a component that's nested within a form but doesn't have direct access to the action. useFormStatus from react-dom solves this by reading the status of the parent form:

Reusable submit button with useFormStatus

import { useFormStatus } from "react-dom";

function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting..." : children}
    </button>
  );
}

Important: useFormStatus must be called from a component that is rendered inside a <form>. It reads the status of the parent form element, not any form passed as a prop.

When to Use What

Here's a quick decision guide:

  • Simple form with server action → Use the action prop directly
  • Form that needs to show results/errorsuseActionState
  • Instant UI feedbackuseOptimistic
  • Submit button in a nested componentuseFormStatus
  • Non-form mutationuseTransition with an async callback

Resources