Skip to main content

Combining useActionState, useFormStatus, and useOptimistic

The power of React 19's action model emerges when you orchestrate useActionState, useFormStatus, and useOptimistic together. Each hook solves a specific problem: state management, pending UI, and instant feedback. Combined, they create forms that feel as responsive as a native app—data updates instantly, errors appear inline, and the UI never feels slow.

This article teaches the pattern you'll use in production: how to compose these hooks, pass state correctly, and handle the full lifecycle of a form submission. By the end, you'll have a mental model for building any form, from simple contact forms to complex multi-step wizards.

The Complete Pattern

Here's a form that combines all three hooks:

async function submitFeedback(prevState, formData) {
const email = formData.get("email");
const message = formData.get("message");

// Validation
const errors = {};
if (!email.includes("@")) errors.email = "Invalid email";
if (message.length < 10) errors.message = "Message too short";

if (Object.keys(errors).length > 0) {
return { errors, success: false };
}

// Server call
try {
const res = await fetch("/api/feedback", {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("Server error");
return { success: true, errors: {} };
} catch (err) {
return { error: err.message, success: false, errors: {} };
}
}

export default function FeedbackForm() {
// Core state management
const [state, dispatch, pending] = useActionState(submitFeedback, {
success: false,
error: null,
errors: {}
});

return (
<form action={dispatch}>
<EmailInput state={state} />
<MessageInput state={state} />
<SubmitButton />
</form>
);
}

function EmailInput({ state }) {
return (
<div>
<label>Email</label>
<input
name="email"
type="email"
disabled={useFormStatus().pending}
/>
{state.errors?.email && <p className="error">{state.errors.email}</p>}
</div>
);
}

function MessageInput({ state }) {
return (
<div>
<label>Message</label>
<textarea
name="message"
disabled={useFormStatus().pending}
/>
{state.errors?.message && <p className="error">{state.errors.message}</p>}
</div>
);
}

function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending} type="submit">
{pending ? "Sending..." : "Send Feedback"}
</button>
);
}

This shows the core pattern:

  1. useActionState: Manages the state returned by the action (errors, success, general error).
  2. useFormStatus (in child components): Accesses pending state without prop drilling.
  3. Individual fields check state.errors[fieldName] and display errors inline.

Adding Optimistic Updates

If you want instant feedback (e.g., showing the message before it's confirmed), add useOptimistic:

export default function FeedbackForm() {
const [state, dispatch, pending] = useActionState(submitFeedback, {
success: false,
error: null,
errors: {},
feedback: []
});

// Show submitted feedback instantly
const [optimisticFeedback, addOptimisticFeedback] = useOptimistic(
state.feedback,
(feedback, newItem) => [...feedback, newItem]
);

return (
<>
<form
action={dispatch}
onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
// Optimistically add the feedback
addOptimisticFeedback({
id: Date.now(),
email: formData.get("email"),
message: formData.get("message"),
pending: true
});
}}
>
<EmailInput state={state} />
<MessageInput state={state} />
<SubmitButton />
</form>

{/* Show submitted feedback */}
<div>
{optimisticFeedback.map(item => (
<div key={item.id} style={{ opacity: item.pending ? 0.6 : 1 }}>
<strong>{item.email}</strong>: {item.message}
</div>
))}
</div>
</>
);
}

Now: user fills form → clicks submit → feedback appears in the list instantly → server confirms → the optimistic item's pending flag changes.

Real-World Example: Todo Application

Here's a complete todo app combining all three hooks:

// actions.js
export async function addTodo(prevState, formData) {
const text = formData.get("text");

if (!text.trim()) {
return { error: "Todo cannot be empty", todos: prevState.todos };
}

try {
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text })
});
if (!res.ok) throw new Error("Failed to add todo");
const newTodo = await res.json();
return { todos: [...prevState.todos, newTodo], error: null };
} catch (err) {
return { error: err.message, todos: prevState.todos };
}
}

export async function toggleTodo(prevState, formData) {
const todoId = parseInt(formData.get("todoId"));

try {
const res = await fetch(`/api/todos/${todoId}/toggle`, {
method: "POST"
});
if (!res.ok) throw new Error("Failed to toggle");
const updated = await res.json();
return { todos: updated, error: null };
} catch (err) {
return { error: err.message, todos: prevState.todos };
}
}
// TodoApp.js
import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";
import { addTodo, toggleTodo } from "./actions";

export default function TodoApp({ initialTodos }) {
// Add todo
const [addState, addDispatch] = useActionState(addTodo, {
todos: initialTodos,
error: null
});

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
addState.todos,
(todos, newTodo) => [...todos, newTodo]
);

return (
<div>
<AddTodoForm addDispatch={addDispatch} addError={addState.error} />
<TodoList todos={optimisticTodos} />
</div>
);
}

function AddTodoForm({ addDispatch, addError }) {
const { pending } = useFormStatus();
const formRef = useRef();

function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(formRef.current);
addDispatch(formData);
formRef.current.reset();
}

return (
<form ref={formRef} onSubmit={handleSubmit} action={addDispatch}>
<input
name="text"
placeholder="Add a new todo..."
disabled={pending}
required
/>
<button disabled={pending} type="submit">
{pending ? "Adding..." : "Add"}
</button>
{addError && <p style={{ color: "red" }}>{addError}</p>}
</form>
);
}

function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}

function TodoItem({ todo }) {
const [state, dispatch] = useActionState(toggleTodo, { todos: [todo] });
const [optimisticCompleted, toggleOptimistic] = useOptimistic(
state.todos[0].completed,
(completed) => !completed
);
const { pending } = useFormStatus();

return (
<form action={dispatch} style={{ display: "inline" }}>
<input type="hidden" name="todoId" value={todo.id} />
<input
type="checkbox"
checked={optimisticCompleted}
onChange={() => {
toggleOptimistic();
document.currentScript?.closest("form")?.requestSubmit?.();
}}
disabled={pending}
/>
<span style={{ textDecoration: optimisticCompleted ? "line-through" : "none" }}>
{todo.text}
</span>
</form>
);
}

This example shows:

  • useActionState: Manages the todo list and error state.
  • useOptimistic: Shows new todos instantly (before server confirms).
  • useFormStatus: Disables buttons and inputs while pending.
  • Reusable actions: addTodo and toggleTodo are pure functions, easy to test.

Handling State Transitions

When combining hooks, manage state transitions carefully. Here's a pattern for multi-step forms:

async function submitStep1(prevState, formData) {
const data = Object.fromEntries(formData);

// Validate step 1
if (!data.name || !data.email) {
return { step: 1, error: "Required fields missing", data: prevState.data };
}

return {
step: 2,
error: null,
data: { ...prevState.data, ...data }
};
}

async function submitStep2(prevState, formData) {
const allData = {
...prevState.data,
...Object.fromEntries(formData)
};

try {
const res = await fetch("/api/submit", {
method: "POST",
body: JSON.stringify(allData)
});
if (!res.ok) throw new Error("Submission failed");

return { step: 3, success: true, data: allData };
} catch (err) {
return { step: 2, error: err.message, data: allData };
}
}

export default function MultiStepForm() {
const [state, dispatch, pending] = useActionState(submitStep1, {
step: 1,
data: {},
error: null
});

return (
<form action={dispatch}>
{state.step === 1 && (
<>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
</>
)}

{state.step === 2 && (
<>
<input name="phone" placeholder="Phone" />
<select name="country">
<option>Country</option>
</select>
{/* Change action for step 2 */}
{/* This requires useActionState handling or a trick */}
</>
)}

{state.step === 3 && <p>Success!</p>}

{state.error && <p style={{ color: "red" }}>{state.error}</p>}

<button disabled={pending} type="submit">
{pending ? "..." : state.step === 1 ? "Next" : "Submit"}
</button>
</form>
);
}

For multi-step forms, track the step in state and swap actions or conditional form fields.

Key Takeaways

  • useActionState: Manages state from the action; pass it to child components.
  • useFormStatus: Accesses pending state and form data in child components without props.
  • useOptimistic: Updates the UI before the server responds; use for instant feedback.
  • Combine all three for complete form UX: validation, loading, error handling, and optimistic updates.
  • Reuse actions across components; keep them pure functions.

Frequently Asked Questions

How do I switch actions between form steps?

Use conditional rendering: check the step in state and render different actions. Or return the next step as part of the state and create a wrapper action that delegates.

Can I have multiple forms on the same page?

Yes. Each form has its own useActionState. Wrap each in a separate form element.

What if useFormStatus is called outside a form?

It returns dummy values: { pending: false, data: null, method: "GET", action: null }. Always check pending.

How do I clear form state after success?

Return a clean state object from the action, or use a useEffect to reset the form ref.

Can I nest forms?

HTML doesn't allow nested forms, but you can have nested components. Each component can be a separate form.

Further Reading