Optimistic Updates
By default every oRPC useMutation() in this codebase auto-invalidates
its owning plugin’s queries on success (see
Query Invalidation for the
full story). That’s the right default — a single round-trip, no stale
data — but on high-frequency cheap toggles it still costs the user a
visible “click → wait → flip” latency.
Optimistic updates close that gap: the cache is patched immediately so the UI flips on click, with a rollback wired up in case the server rejects the mutation.
This page documents the canonical pattern. Today (May 2026) it’s
applied to exactly two mutations — markAsRead on the Notifications
page and pauseConfiguration / resumeConfiguration on the
Health Check Config page. Broader rollout is a v1.1 task; the pattern
below is the contract any new use must follow.
When to use it
Section titled “When to use it”Optimistic UI is a good fit when all of the following hold:
- The mutation is a cheap toggle on local row/item state (mark-as-read, pause/resume, star/unstar, vote up/down).
- The mutation has no server-side side effects beyond the asserted state change. A pause toggle that also reshuffles ten unrelated rows is not a good candidate.
- Rollback is visually obvious. If a failed flip silently undoes a user action two seconds later they’ll think the click “didn’t work” — the rollback only feels right when it’s plausibly tied to the original click.
- The toggled state is local to a single row, not cross-cutting. Cascade effects across multiple cached queries multiply the rollback surface and quickly outgrow the four-step pattern.
When NOT to use it
Section titled “When NOT to use it”- Creates with server-generated IDs. You don’t know the row’s id until the server replies, so the placeholder row has no stable identity, can’t be selected, and gets awkward to reconcile.
- Irreversible mutations.
delete, “send notification now”, “kick user” — anything where the user reasonably expects a confirmation moment. Pretending it already happened robs them of the chance to bail. - Cascade mutations. Anything that changes state in multiple cached queries at once. The four-step pattern below tracks one snapshot; cascades need bespoke handling and usually a redesign.
- Mutations that resolve into a navigation. The render immediately after a successful create/edit usually unmounts the caller — there’s nothing to optimistically update.
The canonical four-step cycle
Section titled “The canonical four-step cycle”Every optimistic mutation MUST follow these four steps in order:
onMutate— cancel any in-flight refetch for the affected query, snapshot the cache, patch it optimistically, return the snapshot as the mutation context.onError— restore the snapshot from the returned context.onSettled— invalidate the affected query so the cache reconciles with server truth (success and error both).- Toast voice — suppress success toasts (the UI already flipped), keep error toasts (the rollback alone isn’t always enough signal).
The exact snippet, using the project’s oRPC useMutation wrapper
(see core/frontend-api/src/orpc-query.tsx):
import { usePluginClient, useQueryClient,} from "@checkstack/frontend-api";import { toastError, useToast } from "@checkstack/ui";import { NotificationApi } from "@checkstack/notification-common";
const queryClient = useQueryClient();const toast = useToast();const notificationClient = usePluginClient(NotificationApi);
// Same query key the loader uses below.const notificationsKey = [ ["notification", "getNotifications"], { input: { limit: 20, offset: 0, unreadOnly: false }, type: "query" },] as const;
const markAsRead = notificationClient.markAsRead.useMutation({ // 1. Cancel + snapshot + patch. onMutate: async ({ notificationId }) => { await queryClient.cancelQueries({ queryKey: notificationsKey }); const previous = queryClient.getQueryData<NotificationsPage>( notificationsKey, ); if (previous) { queryClient.setQueryData<NotificationsPage>(notificationsKey, { ...previous, notifications: previous.notifications.map((n) => n.id === notificationId ? { ...n, isRead: true } : n, ), }); } return { previous }; }, // 2. Roll back from the snapshot. onError: (error, _vars, ctx) => { if (ctx?.previous) { queryClient.setQueryData(notificationsKey, ctx.previous); } toastError(toast, "Failed to mark as read", error); }, // 3. Reconcile with server truth either way. onSettled: () => { void queryClient.invalidateQueries({ queryKey: notificationsKey }); }, // 4. No success toast — the UI flip is the feedback.});Step-by-step justification
Section titled “Step-by-step justification”cancelQueriesbefore snapshotting. Without this, a refetch already in flight when the user clicks can land after the optimistic write and silently overwrite it. The cancel makes the in-flight refetch a no-op so our patch sticks.- Snapshot, then patch. The snapshot is what
onErrorrolls back to. Snapshot first so a thrown patch can’t leave the cache half-modified. - Type the
setQueryDatacallback. Read the existingpreviousvalue with the same type the loader uses (queryClient.getQueryData<TLoaderOutput>(key)). Never reach forany— narrow withif (previous)if the cache miss is possible (it usually is on the first interaction). - Return
{ previous }fromonMutate. TanStack passes this toonErroras the third arg. Don’t stash it in module state. onSettledinvalidates regardless of outcome. On success it pulls server truth back into the cache so any field the optimistic patch didn’t touch (timestamps, derived counts) settles correctly. On error it pulls server truth back so the just-restored snapshot doesn’t itself go stale.- Suppress success toasts. The user clicked “mark as read” and the row faded — that’s the feedback. A toast on top is noise.
- Keep error toasts. The rollback alone tells the user “the UI
is back where it was” but not why.
toastError(toast, "...", err)is the right shape (see List & Query States for thetoastErrorhelper).
The query-key gotcha
Section titled “The query-key gotcha”The optimistic write, the snapshot, the rollback, and the
onSettled invalidation MUST use the same key as the query
being patched. The auto-invalidator in
core/frontend-api/src/orpc-query.tsx
matches by prefix ([[pluginId]]), but setQueryData needs an
exact match — it writes one entry, not “every entry under
prefix”.
oRPC builds query keys in a fixed shape (see
generateOperationKey in @orpc/tanstack-query):
[ [pluginId, ...procedurePath, procedureName], { input?, type? },]For a top-level procedure like notification.getNotifications with
input { limit: 20, offset: 0, unreadOnly: false }, the key is:
[ ["notification", "getNotifications"], { input: { limit: 20, offset: 0, unreadOnly: false }, type: "query" },]If the loader’s input changes (e.g. the user paginates or toggles
a filter), the query key changes too. Either capture the live
input in the same component as the mutation (so both sides agree)
or compose the key once via a useMemo and share it between the
useQuery call and the mutation.
When the loader uses paging/filter state, build the key from the
same state variables the useQuery reads, in the same render. The
two call sites currently using this pattern do exactly that — see
core/notification-frontend/src/pages/NotificationsPage.tsx and
core/healthcheck-frontend/src/pages/HealthCheckConfigPage.tsx.
What auto-invalidation still does
Section titled “What auto-invalidation still does”The oRPC useMutation wrapper still fires its
plugin-wide invalidation on onSuccess — that hasn’t changed. The
optimistic write is additive: it patches the cache before the
network roundtrip starts, and the auto-invalidation pulls server
truth in after the response. On error, our explicit rollback wins
because onError runs before any success path.
If you ever override onSuccess on an optimistic mutation, remember
that the wrapper composes your handler with its built-in
invalidation — you don’t need to invalidate yourself in onSuccess,
only in onSettled (so the error path also gets reconciled).
Anti-patterns
Section titled “Anti-patterns”- Skipping
cancelQueries. Don’t. An in-flight refetch will race and silently overwrite the optimistic write — the user sees a flip, then a half-second later it un-flips, then flips again. Worst of both worlds. - Forgetting
onErrorrollback. Optimistic UI without rollback is just lying. If the mutation can fail (every mutation can fail), rollback is mandatory. - Optimistically updating a create / delete. The pattern only works for in-place mutations. For creates, the server-generated id is a placeholder hazard; for deletes, the irreversibility is.
- Spamming success toasts. The whole point of optimistic UI is that the visual change IS the feedback. A toast on top reintroduces the latency the pattern was supposed to hide.
- Stashing the snapshot in module state. Always return it from
onMutateas the context, and read it fromonError’s third arg. Module state races between concurrent mutations. - Using
anyfor the snapshot type. Narrow withqueryClient.getQueryData<TLoaderOutput>(key)and a runtimeif (previous)guard — the cache miss is a real case.