Hooks

useOptimistic

Keyed optimistic updates for plain props and plain event handlers — props always reclaim, sync throws become rejections, per-key de-duplication, out-of-order resolves lose, and failures are readable.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * One optimistic write, handed to `onAction` (and back to `onError` if it
 * fails). `previous` is the *server* value the optimistic value was derived
 * from — not necessarily what was on screen, because a previous confirmed
 * write may still be showing.
 */
export interface OptimisticChange<TValue> {
  key: string
  /** The value rendered immediately, before the server has been asked. */
  next: TValue

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-optimistic.json

Prompt

Build a React + TypeScript "useOptimistic" hook (React only, no data-fetching
library, no Actions/transitions required). It renders a write before the
server confirms it and takes it back correctly when the server disagrees, for
a KEYED collection driven by ordinary props and ordinary event handlers.

It is deliberately NOT React 19's built-in useOptimistic. That one is bound to
a transition: the optimistic value lives exactly as long as the action, then
React snaps back to the state you passed in; there is no error surface and no
per-key in-flight guard. This one retires an overlay when the *value* changes,
not when a *transition* ends — so it survives the gap between "the write
succeeded" and "the refetch landed", and it hands you the failure.

Contract
- `useOptimistic<TValue>(options): UseOptimisticResult<TValue>`.
- `options.values: Readonly<Record<string, TValue>>` — the server-owned
  values keyed by id. Rebuilt inline every render is fine: only the values are
  compared, never the record's identity.
- `options.onAction: (change) => void | TValue | Promise<void | TValue>` where
  `change = { key, next, previous }`. `previous` is the SERVER value the
  optimistic value was derived from and is also the rollback target.
  Resolving with nothing keeps the optimistic value; resolving with a value
  adopts it as confirmed; rejecting rolls back.
- `options.onError?: (error, change) => void` — fires after the rollback is
  committed, for toasts/logging. It still fires for a failure whose row was
  reclaimed by fresh props, which `get(key).error` cannot express.
- `options.equals?: (a, b) => boolean`, default `Object.is`. REQUIRED for
  object-shaped values: a parent that rebuilds item objects every render hands
  over a new reference each time, and the default comparison would then treat
  every render as a server change and throw the overlay away before it is
  seen. Pass a field comparison instead.
- Returns `{ values, errors, pendingKeys, isPending, get, mutate, reset }`.
  `values` is the effective record (optimistic where one is live, server
  everywhere else); `get(key)` returns `{ value, pending, error }`;
  `mutate(key, next | updater) => boolean`; `reset(key?) => void`.
- `mutate` and `reset` are referentially stable (`useCallback` with an empty
  dependency array).

Behavior
- Props always reclaim. Every overlay records the server value it was derived
  from. The comparison happens at READ time, so the moment the incoming value
  differs — even if it also differs from the optimistic value — props win. A
  counter therefore cannot drift: optimistic 97 -> 98 followed by a server
  truth of 105 renders 105, never 98 and never 106. Do not "fix" this by
  diffing against the optimistic value or by clearing the overlay in an
  effect; reading it at render time is what makes the reclaim flash-free.
- `pending` is NOT reclaimed. It is a fact about a request, not about a value,
  so a row can show the fresh server number and still show its spinner.
- A synchronous throw is still a rejection. `onAction` runs inside
  `new Promise(resolve => resolve(onAction(change)))`. `Promise.resolve(fn())`
  does not work: the exception escapes before Promise.resolve ever sees the
  value and the key stays pending forever.
- Per-key de-duplication through a ref. The in-flight set is a `Set` in a ref
  written synchronously inside `mutate`; a burst of clicks in one task is
  refused after the first, and `mutate` returns false so the call site can say
  "already saving". React state cannot do this — every click in the burst
  would read the same pre-render state. Different keys never block each other.
- Out-of-order resolves lose. Each attempt captures a per-key sequence number
  and must still be the newest when it settles. A superseded settle is dropped
  ENTIRELY: it writes no state AND does not delete the in-flight guard, which
  now belongs to the newer attempt. Releasing the guard before the sequence
  check is the subtle bug here — it lets a duplicate write go out while the
  real one is still on the wire.
- Rollback is loud. On rejection the key returns to `change.previous` and
  `error` is set; a silent revert reads as "my click did nothing" and gets
  clicked again forever. The error is dropped once the server value moves on
  (it described a state that no longer exists) and by `reset(key)`.
- Unmount safe. `aliveRef` is set true INSIDE the mount effect body — not
  merely cleared in cleanup, which would leave StrictMode's second mount
  believing it is dead — and re-checked after every await. A late resolve on
  an unmounted component writes nothing and warns about nothing.
- The overlay map lives in a ref and is mirrored into state for rendering, so
  `mutate` always reads the current overlay even when it is called twice in
  one task. Stale overlays are pruned on the next `mutate`.
- Scope: it overlays values on keys that already exist. `mutate` on a key
  absent from `values` is a no-op returning false — optimistic insert/delete
  is a different problem and belongs in the list state, not here.

Rendering & styling
- The hook renders nothing; consumers own all UI. Suggested wiring: keep the
  trigger a real `button` with `aria-busy={pending}` and `aria-disabled` —
  never the native `disabled` attribute, which blurs the button mid-click and
  loses the user's place; the `mutate` guard is what actually makes the fifth
  rage-click a no-op. Swap the icon for a spinner with `animate-spin
  motion-reduce:animate-none`, render the count with `tabular-nums` so it does
  not jitter, and mount the failure in a `role="alert"` paragraph styled
  `text-destructive` only while `error` is non-null, so it is announced once
  and stays until the reader retries.
- Semantic tokens only (`text-destructive`, `bg-muted`, `text-muted-foreground`,
  `border`, `ring`) so every state inherits the host theme in light and dark.

Customization levers
- Hold vs revert on success — resolving `onAction` with nothing keeps the
  optimistic value until props catch up (no flash between "saved" and "refetch
  landed"). If your data layer refetches instantly and you would rather trust
  it, resolve with the server's number instead, or call `reset(key)` in the
  success path to fall back to props immediately.
- De-dupe vs last-write-wins — the guard suits idempotent toggles (vote,
  follow, connect). For a quantity stepper where the newest value should win,
  call `reset(key)` before `mutate(key, ...)`: that abandons the in-flight
  attempt and the sequence guard still keeps its late answer from landing.
- Error lifetime — errors are tied to the server value they failed against.
  For an error that survives any props change, catch it in `onError` and keep
  it in your own toast queue instead of reading `get(key).error`.
- Comparison — swap `equals` for a deep or field-wise comparison when `TValue`
  is an object, or for a tolerance comparison when it is a float.
- Granularity — one hook instance per collection is the intended shape. A
  single value is just one key; a board with several independent columns is
  usually clearer as one instance per column than one giant key space.

Concepts

  • Props reclaim — an overlay is an opinion about one specific server value, so it stores that value as its base and is compared against props on every read. The instant props disagree the overlay is retired, which is why a counter cannot drift: 97 plus an optimistic vote, answered by a server truth of 105, renders 105 rather than 98 or 106. Retiring on a value change rather than on a transition end is also what keeps the value on screen during the gap between "the write returned" and "the refetch landed".
  • Synchronous-throw-to-rejection — the action is invoked inside a Promise executor rather than passed to Promise.resolve. An onAction that throws before its first await would otherwise escape the click handler entirely and strand the row on a spinner that never stops.
  • Per-key in-flight guard — the set of busy keys lives in a ref and is written synchronously inside mutate, because a burst of clicks in one task all observe the same pre-render state; a state-based guard lets every one of them through. mutate returning false is the call site's cue that its click was swallowed.
  • Sequence-number race guard — each attempt captures a monotonically increasing per-key number and must still be the newest when it settles. A superseded attempt is discarded whole: it writes no state and it does not release the guard, which by then belongs to a newer attempt still on the wire.
  • Loud rollback — a failed write returns to the value it started from and leaves a readable error, because a silent revert is indistinguishable from a click that never registered and simply gets repeated. The error expires when the server value it described expires.
  • Unmount-safe settle — the alive flag is set inside the mount effect body and cleared in cleanup, then re-checked after every await, so a late resolve on a torn-down row lands nowhere: no state write, no console warning, no unhandled rejection.

On This Page