Optimistic updates
Show a new todo the moment it is submitted, confirm it when the real list updates, and roll it back with an error message when the save fails.
This recipe builds a todo list that adds a row before the server has answered. It demonstrates useOptimistic,
keyed For rows that survive confirmation, and error display for a rejected save.
What useOptimistic does#
useOptimistic(source, reducer) returns [state, apply]. state is the value of
source() with reducer applied once for every action that is still pending. apply(action, promise) records an
action; when promise settles, fulfilled or rejected, the action is dropped and state falls back to the real
source. Nothing in the hook reads the promise result: confirmation means that the source now holds the saved data, and
rollback means that it does not.
Two consequences shape the component below.
- The caller must update the real source before the promise settles, or the row disappears for a moment. The component
does not write the source; its parent owns it and passes the
todosaccessor and asavefunction. - The source can contain the saved todo while the action is still pending (the source update and the promise
callbacks are separate microtasks). The reducer therefore skips an action whose
idis already in the list, which avoids a duplicate row and shows the row as confirmed as soon as the source has it. The client generates the id, so the row keeps the same key, and with it the same DOM node, from pending to confirmed.
The component#
import { useOptimistic, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";
export interface Todo {
id: string;
text: string;
}
interface Row extends Todo {
pending: boolean;
}
export interface TodoListProps {
/** The real list. The parent updates it when a save succeeds. */
todos: () => readonly Todo[];
/** Stores a todo. Must update `todos` before it resolves, and reject when the save fails. */
save: (todo: Todo) => Promise<void>;
}
export function TodoList(props: TodoListProps) {
const [rows, addOptimistic] = useOptimistic(
(): readonly Row[] => props.todos().map((todo) => ({ ...todo, pending: false })),
(list, todo: Todo): readonly Row[] =>
list.some((row) => row.id === todo.id) ? list : [...list, { ...todo, pending: true }],
);
const [text, setText] = useStore("");
const [error, setError] = useStore("");
let counter = 0;
const submit = (): void => {
const value = text().trim();
if (value === "") return;
counter += 1;
const todo: Todo = { id: `local-${counter}`, text: value };
const request = props.save(todo);
addOptimistic(todo, request);
setError("");
setText("");
request.catch((cause: unknown) => {
const reason = cause instanceof Error ? cause.message : "unknown error";
setError(`Could not save "${value}": ${reason}`);
});
};
return (
<div>
<ul aria-label="Todos">
<For each={rows()} key={(row) => row.id}>
{(row) => (
<li aria-busy={row().pending}>
{row().text}
{row().pending ? " (saving)" : ""}
</li>
)}
</For>
</ul>
<form
aria-label="Add todo"
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<input
aria-label="New todo"
value={text()}
onInput={(event) => setText(event.currentTarget.value)}
/>
<button type="submit">Add</button>
</form>
<p role="alert">{error()}</p>
</div>
);
}
apply(todo, request)runs afterprops.save(todo)has started and before anything awaits it.useOptimisticattaches its own handlers to the promise, so a rejection is never unhandled. The component adds a second handler withrequest.catchto show the message; the optimistic row is removed by the hook, not by this handler.rows()is an accessor.Forreads it inside its own computation, so a pending row appears and disappears without the component running again.aria-busy={row().pending}is written as"true"or"false":aria-attributes keep a literalfalse.
Wiring it to a source#
The parent owns the confirmed list. Send the new todo to your endpoint, then update that list before save resolves. The endpoint returns the saved todo with the same client-generated id, so confirmation preserves the row's identity.
import { useSignal } from "@shadow-js/core";
import { type Todo, TodoList } from "./todo-list";
export function App() {
const [todos, setTodos] = useSignal<readonly Todo[]>([]);
const save = async (todo: Todo): Promise<void> => {
const response = await fetch("/api/todos", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(todo),
});
if (!response.ok) throw new Error(`Save failed (${response.status})`);
const saved: unknown = await response.json();
if (typeof saved !== "object" || saved === null || !("id" in saved) || !("text" in saved) || saved.id !== todo.id || typeof saved.text !== "string") {
throw new TypeError("Expected the saved todo with its original id");
}
const confirmed: Todo = { id: todo.id, text: saved.text };
setTodos((list) => [...list, confirmed]);
};
return <TodoList todos={todos} save={save} />;
}
The row appears while the request is pending. A successful response confirms it; a rejected request removes the pending row and leaves the error message visible.
Related: useOptimistic, useStore,
For, deferred, fireEvent.