React
|stacknotice.com
11 min left|
0%
|2,200 words
React

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

Complete guide to React 19's new form hooks in Next.js 15. useActionState replaces useFormState, useFormStatus for submit states, and useOptimistic for instant UI.

C
Carlos Oliva
Software Developer
June 5, 202611 min read
Share:
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 or written a new form and hit confusing import errors or unexpected behavior, this guide covers how all of them actually work — together, in real code.


The problem React 19 solved

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

// React 18 pattern — repetitive and error-prone
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
 
async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setIsPending(true);
  try {
    await submitForm(formData);
    setSuccess(true);
  } catch (err) {
    setError('Something went wrong');
  } finally {
    setIsPending(false);
  }
}

React 19 replaces this pattern with hooks that integrate directly with Server Actions and native <form> elements — no e.preventDefault(), no manual state management for loading and errors.


useActionState — the one you'll use most

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

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

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

How it works

const [state, action, isPending] = useActionState(serverAction, initialState);
  • state — the value returned by your Server Action (or initialState on the first render)
  • action — the enhanced action to 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,
    };
  }
 
  // Create the user
  try {
    await createUser(parsed.data);
    return { success: true, message: 'Account created!' };
  } catch (error) {
    return { message: 'An error occurred. Please try again.' };
  }
}
// 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}>
      <div>
        <input name="name" type="text" placeholder="Name" required />
        {state.errors?.name && (
          <p className="text-red-500 text-sm">{state.errors.name[0]}</p>
        )}
      </div>
 
      <div>
        <input name="email" type="email" placeholder="Email" required />
        {state.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email[0]}</p>
        )}
      </div>
 
      <div>
        <input name="password" type="password" placeholder="Password" required />
        {state.errors?.password && (
          <p className="text-red-500 text-sm">{state.errors.password[0]}</p>
        )}
      </div>
 
      {state.message && <p className="text-red-500">{state.message}</p>}
 
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating account...' : 'Sign up'}
      </button>
    </form>
  );
}

The Server Action receives prevState as the first argument (always — this is required for useActionState to work) and formData as the second. The return value becomes the new state.

Type your state explicitly

Defining a SignupState type and using it for both the action return type and useActionState initial value gives you full type safety throughout the form — errors are typed, state.errors?.email autocompletes correctly.


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 of the form, not in the form component itself.

import { useFormStatus } from 'react-dom'; // ✅ stays in react-dom

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}
      className={pending ? 'opacity-60 cursor-not-allowed' : ''}
    >
      {pending ? 'Saving...' : children}
    </button>
  );
}
// Use it inside any form
<form action={action}>
  <input name="email" />
  <SubmitButton>Save changes</SubmitButton>
</form>

useFormStatus also exposes data (the FormData being submitted), method, and action — but pending is what you'll use 95% of the time.

Why it must be a child component

useFormStatus works by reading React context from the parent <form>. If you call it in the same component that renders the <form>, you're outside the form's provider — React can't read the context, so 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>;
}

useOptimistic — instant UI updates

useOptimistic lets you show an optimistic UI 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
);

Real example: an instant todo list

// app/todos/page.tsx
'use client';
 
import { useOptimistic, useActionState } from 'react';
import { addTodo, deleteTodo, type TodoState } from '@/app/actions/todos';
 
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 [state, action] = useActionState(addTodo, {});
 
  async function handleAddTodo(formData: FormData) {
    const text = formData.get('text') as string;
    if (!text.trim()) return;
 
    // Show immediately in the UI
    addOptimisticTodo({ id: crypto.randomUUID(), text, pending: true });
 
    // Then send to server (addTodo is a Server Action)
    await action(formData);
  }
 
  return (
    <div>
      <ul>
        {optimisticTodos.map(todo => (
          <li
            key={todo.id}
            style={{ opacity: todo.pending ? 0.5 : 1 }}
          >
            {todo.text}
            {todo.pending && ' (saving...)'}
          </li>
        ))}
      </ul>
 
      <form action={handleAddTodo}>
        <input name="text" placeholder="New todo" />
        <SubmitButton>Add</SubmitButton>
      </form>
    </div>
  );
}

The optimistic todo appears instantly with opacity: 0.5 while the server saves it. Once the server responds, React replaces the optimistic entry with the real one (with a real ID from the database).

useOptimistic only works during transitions

Call addOptimistic inside a Server Action, startTransition, or an action passed to a form. If you call it in a regular event handler, it won't work correctly.


Combining all three in a real feature

Here's a more complete example: a settings form that uses all three hooks.

// app/settings/ProfileForm.tsx
'use client';
 
import { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
import { updateProfile, type ProfileState } from '@/app/actions/profile';
 
function SaveButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save profile'}
    </button>
  );
}
 
type Profile = { name: string; bio: string };
 
export function ProfileForm({ profile }: { profile: Profile }) {
  const [optimisticProfile, setOptimisticProfile] = useOptimistic(
    profile,
    (_: Profile, updated: Profile) => updated
  );
 
  const [state, action] = useActionState(updateProfile, {} as ProfileState);
 
  async function handleSubmit(formData: FormData) {
    // Optimistic update — show new values immediately
    setOptimisticProfile({
      name: formData.get('name') as string,
      bio: formData.get('bio') as string,
    });
    await action(formData);
  }
 
  return (
    <div>
      {/* Live preview of the profile as the user edits */}
      <div className="preview">
        <h2>{optimisticProfile.name}</h2>
        <p>{optimisticProfile.bio}</p>
      </div>
 
      <form action={handleSubmit}>
        <input name="name" defaultValue={profile.name} />
        <textarea name="bio" defaultValue={profile.bio} />
 
        {state.error && <p className="error">{state.error}</p>}
        {state.success && <p className="success">Saved!</p>}
 
        <SaveButton />
      </form>
    </div>
  );
}

Migrating from useFormState

If you're upgrading from React 18 with 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);

The main differences:

  • Import from react instead of react-dom
  • Returns three values instead of two — isPending is now built in
  • useFormState is still exported for backwards compatibility but is deprecated — it logs a warning in development

Common mistakes

Mistake 1: Importing useActionState from react-dom

// ❌ Wrong import — useActionState doesn't exist in react-dom
import { useActionState } from 'react-dom';
 
// ✅ Correct
import { useActionState } from 'react';

Mistake 2: useFormStatus in the form component itself

Already covered above — it must be in a child. The fix: extract your submit button into its own component.

Mistake 3: Server Action without prevState parameter

useActionState always passes prevState as the first argument to your Server Action. If you forget it, formData will actually be the state object, not the form data.

// ❌ Missing prevState
export async function createPost(formData: FormData) { ... }
 
// ✅ Correct
export async function createPost(prevState: PostState, formData: FormData) { ... }

Mistake 4: Not marking the form component as 'use client'

useActionState and useFormStatus are client hooks. The form component must be a Client Component. The Server Action itself stays on the server.


Quick reference

HookPackagePurpose
useActionStatereactForm state from Server Actions, replaces useFormState
useFormStatusreact-domSubmit button loading state, must be in child component
useOptimisticreactInstant UI update before server responds
useFormStatereact-domDeprecated — migrate to useActionState

Related: React Server Actions Complete Guide — the foundation for these hooks · React Hook Form + Zod Guide — when you need more control than native form actions · Next.js App Router Complete Guide — understanding the architecture these hooks live in

#react#nextjs#typescript#webdev#javascript
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.