[]previous and next page

Mutations and optimistic UI

Mutations and optimistic UI in the Data fetching guide.

For agentsUpdated

A resource does not know about writes. After a write, either refetch() to get the server's version or mutate() to put a value you already have in place. For writes that should show immediately, useOptimistic(source, reducer) returns [state, apply]:

  • state() is the reducer applied to source() once for every action whose promise 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. A rejected promise is therefore a rollback.

Pass a promise that covers the whole operation, including the refetch that brings in the confirmed data. If you pass only the write, the optimistic row disappears the moment the write finishes and the list shows the old data until the refetch ends.

src/todos.tsxtsx
import { useOptimistic, useResource } from "@shadow-js/core";
import { For } from "@shadow-js/web";
import type { Backend, Todo } from "./backend";

export function Todos(props: { backend: Backend }) {
  const todos = useResource(() => props.backend.list());
  const [shown, addOptimistically] = useOptimistic(
    () => todos.latest ?? [],
    (list: readonly Todo[], text: string) => [...list, { id: -1, text }],
  );

  const add = async (text: string): Promise<void> => {
    // The write, then the refetch that brings in the confirmed rows.
    const saving = props.backend.add(text).then(() => todos.refetch());
    addOptimistically(text, saving);
    await saving.catch(() => undefined);
  };

  return (
    <div>
      <button type="button" onClick={() => void add("ship it")}>
        add
      </button>
      <ul>
        <For each={shown()}>{(todo) => <li>{todo.text}</li>}</For>
      </ul>
    </div>
  );
}

The optimistic row above has the placeholder id -1. A keyed For over such rows would key two pending adds identically; give each action a client id when that matters. Because the reducer must be pure, generate the id in the action, not inside the reducer.

mutate is the other tool: todos.mutate((list) => [...(list ?? []), row]) puts a value in place immediately and cancels a fetch that was in flight, so a stale response cannot overwrite it. Use it when the server's answer to the write already contains the new row.