DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

React 19 Form Hooks: useActionState, useFormStatus, and useOptimistic (2026)

React 19 shipped four new hooks specifically for forms: useActionState, useFormStatus, useOptimistic, and useFormState (deprecated). If you've tried to upgrade an existing codebase and hit confusing import errors or unexpected behavior, this guide covers how all of them actually work.


The problem React 19 solved

In React 18, handling a form with a Server Action involved a lot of boilerplate:

const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setIsPending(true);
  try {
    await submitForm(formData);
  } catch (err) {
    setError('Something went wrong');
  } finally {
    setIsPending(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

React 19 replaces this with hooks that integrate directly with Server Actions — no e.preventDefault(), no manual state management.


useActionState — the one you'll use most

useActionState is the main hook for form state management. It replaces the deprecated useFormState from react-dom.

Critical: import it from react, not react-dom.

import { useActionState } from 'react'; // ✅ React 19
// NOT: import { useFormState } from 'react-dom'; // ❌ deprecated
Enter fullscreen mode Exit fullscreen mode

How it works

const [state, action, isPending] = useActionState(serverAction, initialState);
Enter fullscreen mode Exit fullscreen mode
  • state — the value returned by your Server Action
  • action — pass to <form action={action}>
  • isPendingtrue while the action is in flight

A real signup form

// app/actions/auth.ts
'use server';
import { z } from 'zod';

const signupSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  name: z.string().min(1, 'Name is required'),
});

export type SignupState = {
  errors?: { email?: string[]; password?: string[]; name?: string[] };
  message?: string;
  success?: boolean;
};

export async function signup(
  prevState: SignupState,
  formData: FormData
): Promise<SignupState> {
  const parsed = signupSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
    name: formData.get('name'),
  });

  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }

  try {
    await createUser(parsed.data);
    return { success: true, message: 'Account created!' };
  } catch {
    return { message: 'An error occurred. Please try again.' };
  }
}
Enter fullscreen mode Exit fullscreen mode
// app/signup/page.tsx
'use client';
import { useActionState } from 'react';
import { signup, type SignupState } from '@/app/actions/auth';

const initialState: SignupState = {};

export default function SignupPage() {
  const [state, action, isPending] = useActionState(signup, initialState);

  if (state.success) return <p>Account created! Check your email.</p>;

  return (
    <form action={action}>
      <input name="name" type="text" placeholder="Name" required />
      {state.errors?.name && <p className="error">{state.errors.name[0]}</p>}

      <input name="email" type="email" placeholder="Email" required />
      {state.errors?.email && <p className="error">{state.errors.email[0]}</p>}

      <input name="password" type="password" placeholder="Password" required />
      {state.errors?.password && <p className="error">{state.errors.password[0]}</p>}

      {state.message && <p className="error">{state.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating account...' : 'Sign up'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: The Server Action always receives prevState as the first argument. If you forget it, formData will actually be the state object — everything breaks silently.


useFormStatus — loading states in child components

useFormStatus reads the status of the nearest parent <form>. The key constraint: it must be called in a child component, not in the form component itself.

import { useFormStatus } from 'react-dom'; // stays in react-dom
Enter fullscreen mode Exit fullscreen mode

The SubmitButton pattern

// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it must be a child

useFormStatus reads React context from the parent <form>. If you call it in the same component that renders the <form>, you're outside the provider — pending is always false.

// ❌ Wrong — useFormStatus in the same component as the form
function MyForm() {
  const { pending } = useFormStatus(); // always false
  return <form><button disabled={pending}>Submit</button></form>;
}

// ✅ Correct — useFormStatus in a child
function SubmitButton() {
  const { pending } = useFormStatus(); // works correctly
  return <button disabled={pending}>Submit</button>;
}
function MyForm() {
  return <form><SubmitButton /></form>;
}
Enter fullscreen mode Exit fullscreen mode

useOptimistic — instant UI updates

useOptimistic lets you show an update immediately while the server processes the action. If the action fails, React rolls back automatically.

import { useOptimistic } from 'react';

const [optimisticState, addOptimistic] = useOptimistic(
  currentState,
  updateFunction
);
Enter fullscreen mode Exit fullscreen mode

Real example: instant todo list

'use client';
import { useOptimistic, useActionState } from 'react';

type Todo = { id: string; text: string; pending?: boolean };

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

  const [, action] = useActionState(addTodo, {});

  async function handleAddTodo(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimisticTodo({ id: crypto.randomUUID(), text, pending: true });
    await action(formData);
  }

  return (
    <div>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
            {todo.text}
          </li>
        ))}
      </ul>
      <form action={handleAddTodo}>
        <input name="text" placeholder="New todo" />
        <button type="submit">Add</button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning: Call addOptimistic inside a Server Action, startTransition, or a form action — not in a plain event handler. It won't work otherwise.


Migrating from useFormState

// Before (React 18 / react-dom)
import { useFormState } from 'react-dom';
const [state, action] = useFormState(serverAction, initialState);

// After (React 19 / react)
import { useActionState } from 'react';
const [state, action, isPending] = useActionState(serverAction, initialState);
Enter fullscreen mode Exit fullscreen mode

The API is the same — change the import, rename the hook, and you get isPending for free.


Common mistakes

Mistake 1: Wrong import

import { useActionState } from 'react-dom'; // ❌ doesn't exist here
import { useActionState } from 'react';      // ✅
Enter fullscreen mode Exit fullscreen mode

Mistake 2: useFormStatus in the form component — always false. Move to a child.

Mistake 3: Missing prevState in Server ActionformData will be the state object.

// ❌
export async function createPost(formData: FormData) {}
// ✅
export async function createPost(prevState: PostState, formData: FormData) {}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Forgetting 'use client' — these are client hooks. The component must be a Client Component.


Quick reference

Hook Package Purpose
useActionState react Form state + isPending, replaces useFormState
useFormStatus react-dom Submit button state, must be in child component
useOptimistic react Instant UI, auto-reverts on failure

Full guide with more examples: stacknotice.com/blog/react-19-form-hooks-useactionstate-guide-2026

Top comments (0)