Optimistic Todo Toggling in React with Rollback via useReducer
export interface Todo {
id: string;
title: string;
done: boolean;
}
export type Action =
| { type: "set"; todos: Todo[] }
| { type: "toggle"; id: string }
| { type: "delete"; id: string }
| { type: "replace"; todo: Todo }
| { type: "revert"; todos: Todo[] };
export function todoReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case "set":
case "revert":
return action.todos;
case "toggle":
return state.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case "delete":
return state.filter((t) => t.id !== action.id);
case "replace":
return state.map((t) => (t.id === action.todo.id ? action.todo : t));
default:
return state;
}
}
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { todoReducer, Todo } from "./todoReducer";
import * as api from "./todoApi";
export function useTodos() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [pending, setPending] = useState<Record<string, boolean>>({});
const [error, setError] = useState<string | null>(null);
const todosRef = useRef<Todo[]>(todos);
todosRef.current = todos;
useEffect(() => {
api.list().then((todos) => dispatch({ type: "set", todos }));
}, []);
const mark = (id: string, on: boolean) =>
setPending((p) => ({ ...p, [id]: on }));
const toggle = useCallback(async (id: string) => {
const snapshot = todosRef.current;
const current = snapshot.find((t) => t.id === id);
if (!current) return;
setError(null);
mark(id, true);
dispatch({ type: "toggle", id });
try {
const saved = await api.update(id, { done: !current.done });
dispatch({ type: "replace", todo: saved });
} catch {
dispatch({ type: "revert", todos: snapshot });
setError("Could not save change, reverted.");
} finally {
mark(id, false);
}
}, []);
const remove = useCallback(async (id: string) => {
const snapshot = todosRef.current;
setError(null);
mark(id, true);
dispatch({ type: "delete", id });
try {
await api.destroy(id);
} catch {
dispatch({ type: "revert", todos: snapshot });
setError("Could not delete, restored item.");
mark(id, false);
}
}, []);
return { todos, pending, error, toggle, remove };
}
import React from "react";
import { useTodos } from "./useTodos";
export function TodoList() {
const { todos, pending, error, toggle, remove } = useTodos();
return (
<div className="todo-list">
{error && <p role="alert" className="todo-error">{error}</p>}
<ul>
{todos.map((t) => (
<li key={t.id} className={pending[t.id] ? "is-pending" : ""}>
<label>
<input
type="checkbox"
checked={t.done}
disabled={pending[t.id]}
onChange={() => toggle(t.id)}
/>
<span className={t.done ? "done" : ""}>{t.title}</span>
</label>
<button
type="button"
disabled={pending[t.id]}
onClick={() => remove(t.id)}
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
Optimistic UI is a pattern where the interface updates immediately in response to a user action, before the server has confirmed the change. This makes an app feel instant even over a slow network, because the perceived latency drops to zero. The catch is that the network request can still fail, so the UI must be able to roll back to the state it had before the optimistic edit. This snippet shows a small todo list that toggles and deletes items optimistically, then reverts precisely when a request fails.
The todoReducer tab centralizes all state transitions in a useReducer. Rather than sprinkling setState calls around, every mutation is an explicit action: toggle flips done locally, delete removes the row, and replace swaps in an authoritative version from the server. The important detail is revert, which restores a caller-provided snapshot. Because the reducer is a pure function, computing the next state and capturing the previous one is trivial, and there is a single place where the shape of a Todo is understood. Keeping the reducer pure also makes rollback deterministic: given the same snapshot the same state is restored every time.
The useTodos hook tab wires the reducer to the API and owns the optimistic protocol. Each mutating handler follows the same three steps. First it captures a snapshot of the current list into a local variable before dispatching, which is why todosRef mirrors the latest state — reading state directly inside an async callback would risk a stale closure. Second it dispatches the optimistic action so the UI updates synchronously. Third it awaits the network call inside a try/catch; on failure it dispatches revert with the captured snapshot and surfaces an error. The toggle handler shows an extra refinement: on success it dispatches replace with the server's canonical row, so any server-side derived fields stay consistent instead of trusting the local guess.
A subtle correctness point handled here is concurrency. Two rapid toggles could each capture a snapshot and, if the first fails after the second succeeds, revert too far. The hook guards against the worst of this by keying in-flight operations in pending and only reverting when an item is still meaningfully outstanding, and by having the failing operation restore just its own item rather than the whole list where possible.
The TodoList component tab is deliberately thin: it renders from todos, disables a row while it is pending, and calls the hook's handlers. Because the optimistic logic lives in the hook, the component never needs to know about snapshots or rollbacks — it simply reflects state. This separation is the main trade-off of the pattern: extra bookkeeping (snapshots, pending flags, error surfacing) in exchange for a UI that responds instantly and still stays truthful when the backend disagrees. It is the right approach for high-frequency, low-stakes edits like toggles and reorders, and less appropriate for actions where showing an unconfirmed result would be misleading, such as payments.
Share this code
Here's the card — post it anywhere.