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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-optimistic.jsonPrompt
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
baseand 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. AnonActionthat 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.mutatereturning 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.
useNotification
Sends desktop notifications behind a gesture-gated requestPermission() that never throws, keeps the permission state live, registers every notification by tag so close(tag) works, and skips notifications while the page is visible.
useEventSource
Subscribe to a Server-Sent Events stream with a four-state connection machine, capped exponential-backoff reconnects, named-event subscriptions and callbacks that never re-open the stream.