Skip to main content

Optimistic UI Updates with useOptimistic Hook

The useOptimistic hook lets you update the UI instantly, before the server confirms the change. When a user likes a post, the heart turns red immediately—no loading spinner. If the server call fails, React rolls back the UI to the previous state. This pattern, called optimistic updating, is the hallmark of fast, responsive web apps.

useOptimistic works hand-in-hand with useActionState. You pass a reducer function that predicts how the state will change, and useOptimistic applies that change immediately while the action is pending. When the action resolves, React swaps in the real server response. This gives users the perception of zero latency.

The Problem useOptimistic Solves

Without optimistic updates, here's the typical flow: user clicks "Like" → spinner appears → server processes → UI updates. There's a noticeable delay.

With optimistic updates: user clicks "Like" → UI updates instantly → server processes → UI confirmed (or rolled back if error). The delay is invisible.

Consider a todo list. When the user checks off a todo, they expect the checkbox to toggle instantly, even though the server call takes 200ms:

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

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.todos };
}

export default function TodoList({ initialTodos }) {
const [state, dispatch] = useActionState(toggleTodo, {
todos: initialTodos
});

return (
<div>
{state.todos.map(todo => (
<form key={todo.id} action={dispatch} style={{ display: "inline" }}>
<input type="hidden" name="todoId" value={todo.id} />
<input
type="checkbox"
defaultChecked={todo.completed}
onChange={(e) => e.currentTarget.form.requestSubmit()}
/>
<span>{todo.text}</span>
</form>
))}
</div>
);
}

The checkbox updates only after the server responds. With useOptimistic, it updates instantly.

The Basic Pattern: useOptimistic

useOptimistic takes two arguments:

  1. State: The current state (from useActionState).
  2. Reducer: A function (currentState, optimisticValue) => newState.

It returns the state that the UI should display (either the real state or the optimistic state).

import { useActionState } from "react";
import { useOptimistic } from "react";

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

const res = await fetch(`/api/todos/${todoId}/toggle`, {
method: "POST"
});

const updated = await res.json();
return { todos: updated.todos };
}

export default function TodoList({ initialTodos }) {
const [state, dispatch] = useActionState(toggleTodo, {
todos: initialTodos
});

// useOptimistic predicts the new state
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
state.todos,
(todos, todoId) => {
// Reducer: toggle the todo with ID todoId
return todos.map(t =>
t.id === todoId ? { ...t, completed: !t.completed } : t
);
}
);

return (
<div>
{optimisticTodos.map(todo => (
<form key={todo.id} action={dispatch} style={{ display: "inline" }}>
<input type="hidden" name="todoId" value={todo.id} />
<input
type="checkbox"
checked={todo.completed}
onChange={() => {
// Optimistically update the UI
addOptimisticTodo(todo.id);
// Then submit the form
// (dispatch will be called by the form)
}}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</span>
</form>
))}
</div>
);
}

Here's what happens:

  1. User clicks checkbox → onChange calls addOptimisticTodo(todo.id).
  2. useOptimistic applies the reducer instantly: toggles the todo's completed flag.
  3. UI shows the new checkbox state immediately.
  4. Form submission calls dispatch, which calls toggleTodo on the server.
  5. Server responds with the updated todos.
  6. React replaces the optimistic state with the real state.

Binding Optimistic Updates to Form Actions

Usually, you want the optimistic update to happen when the form is submitted, not separately. Use a helper function:

export default function TodoList({ initialTodos }) {
const [state, dispatch] = useActionState(toggleTodo, {
todos: initialTodos
});

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
state.todos,
(todos, todoId) => {
return todos.map(t =>
t.id === todoId ? { ...t, completed: !t.completed } : t
);
}
);

function handleToggle(todoId) {
addOptimisticTodo(todoId);

// Manually dispatch the form
const form = document.querySelector(`[data-todo-id="${todoId}"]`);
form?.requestSubmit();
}

return (
<div>
{optimisticTodos.map(todo => (
<div key={todo.id}>
<form
data-todo-id={todo.id}
action={dispatch}
style={{ display: "inline" }}
>
<input type="hidden" name="todoId" value={todo.id} />
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.text}</span>
</form>
</div>
))}
</div>
);
}

Now: user clicks checkbox → handleToggle calls addOptimisticTodo (UI updates) → form submits → server responds → state updates.

Optimistic Updates for Lists: Adding/Removing Items

useOptimistic shines when adding or removing list items. Show the new item instantly:

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

const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text })
});

const todo = await res.json();
return {
todos: [...prevState.todos, todo]
};
}

export default function TodoForm({ initialTodos }) {
const [state, dispatch] = useActionState(addTodo, {
todos: initialTodos
});

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
state.todos,
(todos, newTodo) => {
return [...todos, newTodo];
}
);

function handleSubmit(e) {
const form = e.currentTarget;
const text = form.text.value;

// Optimistically add the todo
addOptimisticTodo({
id: Date.now(), // Temporary ID
text,
completed: false
});

// Submit the form (will replace optimistic todo with server version)
form.requestSubmit();
form.reset();
}

return (
<>
<form onSubmit={handleSubmit}>
<input name="text" type="text" placeholder="New todo..." />
<button type="submit">Add</button>
</form>

<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{
opacity: todo.id === Date.now() ? 0.6 : 1
}}>
{todo.text}
</li>
))}
</ul>
</>
);
}

When the user types and clicks "Add", the new todo appears in the list instantly. Once the server responds with the real todo (with a real ID), React swaps it in.

Handling Optimistic Rollback on Error

If the server rejects the action, useOptimistic automatically rolls back to the previous state. You can also handle errors explicitly:

export default function TodoList({ initialTodos }) {
const [state, dispatch] = useActionState(toggleTodo, {
todos: initialTodos,
error: null
});

const [optimisticTodos, addOptimisticTodo] = useOptimistic(
state.todos,
(todos, todoId) => {
return todos.map(t =>
t.id === todoId ? { ...t, completed: !t.completed } : t
);
}
);

return (
<>
{state.error && (
<div style={{ background: "#fee", color: "#c00", padding: "10px" }}>
Failed to update: {state.error}
</div>
)}

<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>
<form action={dispatch} style={{ display: "inline" }}>
<input type="hidden" name="todoId" value={todo.id} />
<input
type="checkbox"
checked={todo.completed}
onChange={() => {
addOptimisticTodo(todo.id);
document.currentScript?.parentElement?.requestSubmit?.();
}}
/>
{todo.text}
</form>
</li>
))}
</ul>
</>
);
}

If toggleTodo returns an error, the state shows the error and optimisticTodos reverts to state.todos.

Advanced: Combining useActionState, useFormStatus, and useOptimistic

Here's a complete example combining all three hooks:

async function likePost(prevState, formData) {
const postId = formData.get("postId");
const res = await fetch(`/api/posts/${postId}/like`, {
method: "POST"
});
if (!res.ok) throw new Error("Failed to like");
return { liked: true, postId };
}

function LikeButton({ postId, initialLiked }) {
const [state, dispatch] = useActionState(likePost, {
liked: initialLiked
});

const [optimisticLiked, setOptimisticLiked] = useOptimistic(
state.liked,
(liked) => !liked
);

const { pending } = useFormStatus();

return (
<form action={dispatch} style={{ display: "inline" }}>
<input type="hidden" name="postId" value={postId} />
<button
type="submit"
disabled={pending}
onClick={() => setOptimisticLiked(true)}
style={{ color: optimisticLiked ? "red" : "gray" }}
>
{optimisticLiked ? "♥" : "♡"}
</button>
</form>
);
}
  • useActionState: manages the real state from the server.
  • useOptimistic: shows the predicted UI instantly.
  • useFormStatus: disables the button while pending.

Key Takeaways

  • useOptimistic updates the UI instantly while the action is pending, then swaps in the server response.
  • Pass state and a reducer that predicts how state will change.
  • Call the returned setter (e.g., addOptimisticTodo) before form submission to trigger the optimistic update.
  • useOptimistic automatically rolls back if the action fails.
  • Use with useActionState and useFormStatus for complete form UX.

Frequently Asked Questions

What if the optimistic update is wrong?

useOptimistic rolls back automatically when the action completes. If the server returns different data, the UI swaps to the real state.

Can I use useOptimistic without useActionState?

Yes, but it's less useful. useOptimistic needs state to work with. You could manage state with useState, but useActionState is the natural pair.

How do I generate temporary IDs for optimistic items?

Use Date.now(), crypto.randomUUID(), or a counter. Once the server responds with a real ID, React swaps it in.

Does useOptimistic work with Server Actions?

Yes. useOptimistic doesn't care where the action runs—it works with any function bound via useActionState.

Can I show an optimistic state while a request is pending?

Yes. useOptimistic returns the optimistic state. Show it in the UI. When the action settles, it'll either stay the same (success) or revert (error).

Further Reading