Skip to content

React 19: useActionState, useOptimistic, and the end of manual loading states

Tuan Nguyen Duc Anh
Published date:
Edit this post

React 19 is the most important update since the introduction of Hooks. It doesn’t bring radical new concepts — it brings the definitive solution to a problem we solved a thousand times in different ways: handling forms and mutations.

Table of contents

Open Table of contents

The problem React 19 solves

Before React 19, a form with loading feedback, error handling, and optimistic updating required this:

// Before: 35+ lines for something "basic"
function ProfileForm() {
  const [isPending, setIsPending] = useState(false); 
  const [error, setError] = useState<string | null>(null); 
  const [success, setSuccess] = useState(false); 

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault(); 
    setIsPending(true); 
    setError(null); 
    try {
      const data = new FormData(e.currentTarget); 
      await updateProfile(data); 
      setSuccess(true); 
    } catch (err) {
      setError("Error saving"); 
    } finally {
      setIsPending(false); 
    } 
  }
  // ...
}before.tsx

useActionState: forms without manual useState

import { useActionState } from "react"; 

async function updateProfileAction(prevState: State, formData: FormData) {
  try {
    await updateProfile({
      name: formData.get("name") as string,
      bio: formData.get("bio") as string,
    });
    return { success: true, error: null };
  } catch {
    return { success: false, error: "Error saving profile" };
  }
}

function ProfileForm() {
  const [state, action, isPending] = useActionState(
    updateProfileAction,
    { success: false, error: null }
  );

  return (
    <form action={action}>
      <input name="name" placeholder="Name" />
      <textarea name="bio" placeholder="Biography" />

      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Saved!</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}profile-form.tsx

useOptimistic: instant UI with automatic rollback

The optimistic update pattern (updating the UI before the server confirms) was tedious. Now:

import { useOptimistic, useActionState } from "react";

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

  async function addTodoAction(_: State, formData: FormData) {
    const title = formData.get("title") as string;

    // Immediate UI update
    addOptimisticTodo({ id: crypto.randomUUID(), title, done: false }); 

    // Real mutation (the hook reverts if it fails)
    await createTodo(title);
    return { error: null };
  }

  const [state, action, isPending] = useActionState(addTodoAction, {
    error: null,
  });

  return (
    <>
      <ul>
        {optimisticTodos.map(todo => (
          <li
            key={todo.id}
            style={{ opacity: todo.id.startsWith("temp") ? 0.5 : 1 }}
          >
            {todo.title}
          </li>
        ))}
      </ul>
      <form action={action}>
        <input name="title" required />
        <button disabled={isPending}>Add</button>
      </form>
    </>
  );
}todo-list.tsx

use(): consuming Promises and context conditionally

import { use, Suspense } from "react";

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise); // — can be used inside conditionals

  return <h1>{user.name}</h1>;
}

// The Suspense boundary caches and resolves the promise
function App() {
  const userPromise = fetchUser("123"); // created outside the component

  return (
    <Suspense fallback={<p>Loading user…</p>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}user-profile.tsx

Server Actions in practice

React 19 formalizes Server Actions (functions marked with "use server" that run on the server):

"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath("/posts"); // invalidates server cache
}actions.ts
import { deletePost } from "./actions";

export function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <form action={deletePost.bind(null, post.id)}>
        <button type="submit">Delete</button>
      </form>
    </article>
  );
}post-card.tsx

Summary of new APIs

APIReplacesWhen to use
useActionStateuseState + useReducer for formsAny mutation with UI feedback
useOptimisticManual rollback logicUpdates that improve perceived performance
use(promise)useEffect + useState for data fetchingComponents reading promises in render
use(context)useContextWhen you need to read it conditionally
ref as propforwardRefAlways — removes the unnecessary wrapper
Previous
Modern CSS in 2026: container queries, :has() and anchor positioning
Next
PostgreSQL and JSONB: the power of a relational database with document flexibility