[]previous and next page

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.

For agentsUpdated

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 todos accessor and a save function.
  • 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 id is 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#

src/todo-list.tsxtsx
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 after props.save(todo) has started and before anything awaits it. useOptimistic attaches its own handlers to the promise, so a rejection is never unhandled. The component adds a second handler with request.catch to show the message; the optimistic row is removed by the hook, not by this handler.
  • rows() is an accessor. For reads 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 literal false.

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.

src/app.tsxtsx
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.