Commiq Docs
Usage Patterns

Optimistic Updates

Update state immediately for responsiveness and reconcile when the async operation completes.

Optimistic Updates

An optimistic update applies the expected state change before the server confirms it, and corrects it if the operation fails. A command handler is a natural home for this: setState at the top, async work, then reconcile.

The pattern

Capture and apply

Read the value you may need to restore, then set the expected outcome immediately.

Reconcile

On success, confirm with server-returned data. On failure, restore the captured value and emit an error event.

Each setState publishes its own stateChanged, so the optimistic write, the confirmation, and a rollback are three distinct events — visible in devtools and observed by useSelector individually.

Declare state readonly

Optimistic updates read a value out of state and write it back later. That round trip only typechecks if the state fields are readonly.

ctx.state is DeepReadonly<S>, so a value read from a mutable field comes back as its readonly form and is not assignable to S:

type Bad = { settings: { theme: string; tags: string[] } };
type Good = {
  readonly settings: { readonly theme: string; readonly tags: readonly string[] };
};
// With Bad: TS2345 — readonly string[] is not assignable to string[]
const snapshot = ctx.state.settings;
ctx.setState((prev) => ({ ...prev, settings: snapshot }));

// With Good: fine

There is no need to copy the snapshot. Outside production the store deep-freezes state, so the object you captured cannot change underneath you — { ...ctx.state.settings } buys nothing and, on a mutable field type, fails to compile for the same reason.

Optimistic list add

Add the item immediately with a pending marker; replace it on success, remove it on failure.

stores/todo.ts
import { createCommandDef, createEvent } from "@naikidev/commiq";

type Todo = {
  readonly id: string;
  readonly text: string;
  readonly pending: boolean;
};

type TodoState = {
  readonly items: readonly Todo[];
};

export const TodoCommand = {
  add: createCommandDef<{ text: string }>("todo:add"),
};

export const TodoEvent = {
  AddFailed: createEvent<{ tempId: string; message: string }>("todo:addFailed"),
};

_store.addCommandHandler(TodoCommand.add, async (ctx, cmd) => {
  const tempId = `temp-${crypto.randomUUID()}`;

  // ── Optimistic ──
  ctx.setState((prev) => ({
    items: [...prev.items, { id: tempId, text: cmd.data.text, pending: true }],
  }));

  try {
    const created = await createTodoOnServer(cmd.data.text);

    // ── Confirm ──
    ctx.setState((prev) => ({
      items: prev.items.map((item) =>
        item.id === tempId
          ? { id: created.id, text: created.text, pending: false }
          : item,
      ),
    }));
  } catch (error) {
    // ── Rollback ──
    ctx.setState((prev) => ({
      items: prev.items.filter((item) => item.id !== tempId),
    }));
    ctx.emit(TodoEvent.AddFailed, {
      tempId,
      message: error instanceof Error ? error.message : "Could not save",
    });
    throw error;
  }
});

The throw error after the rollback is deliberate. Without it the handler swallows the failure: the command settles as handled, nothing reaches onError, no commandHandlingError is published, and useCommandStatus reports success. Re-throwing keeps the observability channels honest while still leaving state correct — the rollback already ran.

If re-throwing is not what you want — because the emitted event is your error channel and a reported failure would be noise — that is a legitimate choice, but make it consciously.

Use the updater form ((prev) => ...) rather than reading ctx.state after an await. Both work — ctx.state is a live getter — but the updater makes it obvious that the write is based on whatever state exists at that moment, which matters when other commands ran in between.

The pending marker lets the UI render unconfirmed items differently:

components/TodoItem.tsx
function TodoItem({ item }: { item: DeepReadonly<Todo> }) {
  return (
    <li style={{ opacity: item.pending ? 0.6 : 1 }}>
      {item.text}
      {item.pending && <span> (saving…)</span>}
    </li>
  );
}

A per-item marker is the right tool here; useCommandStatus is keyed by command name, so it cannot tell you which row is saving.

Optimistic toggle

stores/bookmark.ts
type BookmarkState = {
  readonly bookmarked: boolean;
};

export const BookmarkEvent = {
  ToggleFailed: createEvent<{ articleId: string }>("bookmark:toggleFailed"),
};

_store.addCommandHandler(BookmarkCommand.toggle, async (ctx, cmd) => {
  const previous = ctx.state.bookmarked;

  ctx.setState((prev) => ({ ...prev, bookmarked: !previous }));

  try {
    await (previous ? removeBookmark(cmd.data.articleId) : addBookmark(cmd.data.articleId));
  } catch (error) {
    ctx.setState((prev) => ({ ...prev, bookmarked: previous }));
    ctx.emit(BookmarkEvent.ToggleFailed, { articleId: cmd.data.articleId });
    throw error;
  }
});

Surface the failure as a transient notification — see event-driven side effects:

useEvent(bookmarkStore, BookmarkEvent.ToggleFailed, () => {
  showToast("Could not update bookmark. Try again.", { type: "error" });
});

Rapid toggling queues one command per click and they run in order, so the final state is consistent — but every click makes a request. For last-click-wins, register the handler { interruptable: true } and pass ctx.signal to the request, or move the request into an effect with the default mode: "switch".

Rollback on interrupt

For an interruptable command, the store can restore the pre-command state itself:

_store.addCommandHandler(
  BookmarkCommand.toggle,
  async (ctx, cmd) => {
    ctx.setState((prev) => ({ ...prev, bookmarked: !prev.bookmarked }));
    await syncBookmark(cmd.data.articleId, { signal: ctx.signal });
  },
  { interruptable: true, rollbackOnInterrupt: true },
);

When a newer instance supersedes this one, the store restores the state captured before the command started and publishes it as its own stateChanged, then publishes commandInterrupted and settles the handle as interrupted.

This covers exactly one case: an optimistic write abandoned because the command was superseded. It does not roll back on failure — a handler that throws keeps whatever state it wrote, so the catch block above is still required.

rollbackOnInterrupt restores the whole state object, not the field you touched. If the handler awaited and another command modified an unrelated field in the meantime, that change is reverted too. It fits handlers that own their slice of state and are short-lived; for anything else, roll back explicitly.

Snapshot and restore

For a nested object, capture it and write it back:

stores/settings.ts
type Settings = {
  readonly theme: string;
  readonly density: "compact" | "comfortable";
};

type SettingsState = {
  readonly settings: Settings;
};

_store.addCommandHandler(SettingsCommand.update, async (ctx, cmd) => {
  const snapshot = ctx.state.settings;

  ctx.setState((prev) => ({ ...prev, settings: { ...prev.settings, ...cmd.data } }));

  try {
    await saveSettings(cmd.data);
  } catch (error) {
    ctx.setState((prev) => ({ ...prev, settings: snapshot }));
    ctx.emit(SettingsEvent.UpdateFailed, {
      message: error instanceof Error ? error.message : "Could not save",
    });
    throw error;
  }
});

Capture the snapshot before the first setState. Afterwards ctx.state reflects the new value and the original is gone. Because state is frozen, holding the reference is sufficient — no copy needed.

For undo across several commands rather than within one, withHistory(store, { maxEntries }) from commiq-context keeps a bounded buffer of transitions and exposes ctx.history.previous and ctx.history.entries. Note it takes the store as its first argument, so the store must exist before the extension is built:

const _store = createStore<SettingsState>(initialState);
_store.useExtension(withHistory(_store, { maxEntries: 20 }));

When not to use optimistic updates

ScenarioWhy to avoid
The operation returns data the UI needs (IDs, timestamps, computed totals)The optimistic state is incomplete; a component reading the real ID breaks
The operation has real-world side effects (sends an email, charges a card)Rolling back state does not undo the action
Failures are commonUsers see constant flicker between optimistic and rolled-back states
Several users edit the same resourceThe optimistic state may conflict with another writer

In these cases show pending state and wait for confirmation — see async loading states, where useCommandStatus gives you pending without adding a field to state.

On this page