Combining with useOptimistic

useActionState() and useOptimistic() are commonly used together: useActionState() tracks the pending status and the committed result of an action across submissions, while useOptimistic() shows a value for that same data immediately, before the action has actually finished. Below, both wrap a single growing list of todos.

The form's action prop points at a small wrapper function. That wrapper first calls addOptimisticTodo() — safe here because the wrapper itself is already running inside the transition React wraps around a <form> action — and then calls and awaits submitAction(), the dispatch function returned by useActionState(), which runs the real (simulated) save and updates the committed todos list.

Add a todo — it appears instantly, labeled '(saving...)' via useOptimistic(), while useActionState() tracks isPending and threads the growing list through as state. After the simulated one-second save, the label disappears once the committed list includes the new item.
RESETRUNFULL
<!DOCTYPE html><html><head>
<script type="importmap">
{"imports": {"react": "https://esm.sh/react@19", "react-dom/client": "https://esm.sh/react-dom@19/client"}}
</script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head><body>
<div id="root"></div>
<script type="text/babel" data-type="module" data-presets="react">
import { useActionState, useOptimistic } from 'react';
import { createRoot } from 'react-dom/client';

let nextId = 3;
async function saveTodo(text) {
  await new Promise((resolve) => setTimeout(resolve, 1000)); // simulate a slow server
  return { id: nextId++, text };
}

const initialTodos = [
  { id: 1, text: 'Learn useActionState' },
  { id: 2, text: 'Learn useOptimistic' }
];

async function addTodoAction(previousTodos, formData) {
  const newTodo = await saveTodo(formData.get('todo'));
  return [...previousTodos, newTodo];
}

function App() {
  const [todos, submitAction, isPending] = useActionState(addTodoAction, initialTodos);
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newText) => [...currentTodos, { id: 'temp', text: newText + ' (saving...)' }]
  );

  async function formAction(formData) {
    addOptimisticTodo(formData.get('todo')); // instant feedback
    await submitAction(formData); // runs the real save, then updates 'todos'
  }

  return (
    <div>
      <ul>
        {optimisticTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}
      </ul>
      <form action={formAction}>
        <input type="text" name="todo" disabled={isPending} />
        <button type="submit" disabled={isPending}>Add</button>
      </form>
    </div>
  );
}
createRoot(document.getElementById('root')).render(<App />);
</script>
</body></html>