useMutationQueue
A durable offline write queue: enqueue with an optimistic id that doubles as an idempotency key, persist it so a reload keeps the work, drain in order when the connection returns, and park a poisoned write instead of letting it block the rest.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-mutation-queue.jsonPrompt
Build a React + TypeScript "useMutationQueue" hook (React only — no data
library, no service worker, no network code of its own). It owns the writes an
app accepts while it cannot send them: it queues them, keeps them across a
reload, and replays them in order when the connection returns.
Contract
- `useMutationQueue<TPayload, TResult = void>(options): UseMutationQueueResult<TPayload>`.
- `QueuedMutation<TPayload> = { id, payload, label, status, attempts,
createdAt, nextAttemptAt, error }`, every field readonly.
`status: "queued" | "sending" | "failed"`. Success is NOT a status — a
confirmed write leaves the queue, so `items` is always the outstanding work
and never a history nobody prunes. `error` is a string, not an Error: the
queue is persisted as JSON and an Error serializes to `{}`.
- `options.send: (item, signal) => Promise<TResult>` — the only required
option. Resolving means the server owns it; rejecting means try again unless
`shouldRetry` disagrees.
- `options.key = "mutation-queue"` (storage key), `online = true`,
`autoDrain = true`, `maxAttempts = 3` (INCLUDING the first send; 1 disables
retrying), `backoff = attempts => min(30000, 1000 * 2 ** (attempts - 1))`,
`shouldRetry?: (error, item) => boolean`,
`storage?: MutationQueueStorage | null` (defaults to localStorage when it
exists, `null` = memory only), `createId = crypto.randomUUID` fallback,
`now = Date.now`, `onSuccess?: (result, item) => void`,
`onFailure?: (error, item) => void`.
- `MutationQueueStorage = { getItem, setItem, removeItem }` — the slice of
`Storage` used, so `localStorage`, `sessionStorage`, a memory shim in tests
and an IndexedDB adapter are all interchangeable.
- Returns `{ items, pending, failed, isDraining, isHydrated, enqueue, drain,
retry, remove, clear }`. `pending` = queued + sending (the "N changes
waiting" number); `failed` = parked items. `isHydrated` is false until the
mount effect has read storage — gate any "all synced" copy on it or it lies
for one frame on every load.
- `enqueue(payload, meta?) => QueuedMutation` returns the item SYNCHRONOUSLY.
- `drain() => Promise<DrainOutcome>` where `DrainOutcome` is
`{ status: "drained"; sent; failed; left } | { status: "idle" } |
{ status: "offline" } | { status: "busy" } | { status: "cancelled" }`.
It NEVER rejects: a per-item failure is a number, a refusal is a value.
- `enqueue`, `drain`, `retry`, `remove` and `clear` are referentially stable
(useCallback over stable deps), safe as effect dependencies.
Behavior
- The id is the contract. `enqueue` mints it, commits the item and hands it
back inside the same event, so the caller can paint an optimistic row keyed
by it immediately. The same id rides every attempt, which makes it an
idempotency key — mandatory here, because a queue that survives a reload is
at-least-once by construction: a request whose response was lost WILL be
replayed, and only the server can tell that it already applied it.
- State lives in a tiny per-instance external store read through
`useSyncExternalStore`, not in `useState`. The drain loop and `enqueue` must
read the CURRENT queue synchronously, mid-task, right after writing it;
`useState` only offers the value React last rendered. The server snapshot is
a frozen empty queue, so nothing reads storage during render and SSR and
hydration cannot disagree.
- Persistence happens in the same synchronous block as the state change:
serialize, write storage, then notify React. Persisting from a passive
effect would still be pending if the user closed the tab on the click that
queued the write — the exact moment the hook exists to survive. Skip the
write when the serialized string is unchanged.
- Storage is untrusted input. Restore validates every entry (string id, finite
attempts, finite createdAt) and drops what does not fit, refuses an envelope
whose `version` is not the current one, and never throws on hand-edited or
half-written JSON. `sending` is restored as `queued` (its fate is unknown,
so it is replayed) and `nextAttemptAt` is dropped (a deadline from a session
that ended days ago, on a clock that may have moved, means nothing).
Persistence failures — quota exceeded, a privacy mode that blocks writes, a
`File` in the payload that cannot be stringified — are non-fatal: the queue
keeps working in memory and warns once in development.
- The drain is sequential and in queue order. "Create, then rename" replayed
concurrently can land backwards. Each iteration re-reads the store, so an
item enqueued mid-pass is picked up by the same pass and an item removed
mid-pass is simply gone. Exactly one item is `sending` at a time.
- One pass at a time. `drain()` reads AND writes a busy ref in one synchronous
block, so a double click cannot open a second pass; the second call resolves
`{ status: "busy" }`. Refusing beats superseding: these writes may already
have reached the server.
- A poisoned write parks, it does not block. On rejection, ask
`shouldRetry(error, item)`; if false, park immediately with `status:
"failed"` — five retries cannot fix a 422, they only multiply the failure.
Otherwise increment `attempts`; at `attempts >= maxAttempts` park, else set
`nextAttemptAt = now() + backoff(attempts)` and keep the error text on the
row so the UI can say why it is waiting. The drain STEPS OVER parked items
and finishes the rest; a strict head-of-line queue would freeze every later
edit behind one bad request forever. Only `retry(id?)` (re-arm, attempts
back to 0, also skips a pending backoff wait) or `remove(id)` moves a parked
item.
- Connectivity is injected, never sniffed. While `online` is false nothing is
sent — a request with no link is a guaranteed failure and the answer is
already known — and `drain()` resolves `{ status: "offline" }` before
building anything. The flag is re-read every iteration, so losing the link
mid-pass stops the loop and leaves the remainder queued. Feed it from
`use-online`, from a reachability probe, or from your socket; the hook
refuses to bake in `navigator.onLine`, which calls a captive portal
"online".
- Time is injected too. `now()` is called only inside handlers, effects and
timers, never during render. Backoff is stored as a DEADLINE, not a
countdown, so a background tab whose timers are throttled to once a minute
wakes up and sees the truth. One `setTimeout` is armed for the earliest
deadline in the queue (nothing else re-runs when a deadline merely passes),
re-armed after every pass and cleared whenever the link drops.
- Degenerate inputs clamp instead of throwing: `maxAttempts < 1` becomes 1, a
negative delay becomes 0, and a `backoff` that returns NaN becomes 0 — an
unclamped NaN deadline would make `nextAttemptAt <= now` false forever and
silently strand the item.
- Options are read from a latest-ref at the moment they are used, so an inline
`send={async () => …}` arrow costs nothing and flipping `online` or raising
`maxAttempts` mid-pass takes effect on the next decision.
- Cleanup: unmount invalidates the pass id, aborts the in-flight request,
clears the wake timer and resets the busy gate. Every late resolve compares
its captured pass id before touching state, so nothing writes to a torn-down
component and no unhandled rejection escapes. `remove()` on the item that is
on the wire aborts it too — discarding a queued write is a local decision,
not an undo, and the request may still land.
Rendering & styling
- The hook renders nothing; consumers own the UI. Semantic tokens only:
`bg-muted/40` + `border` for a waiting row, `border-destructive/40` +
`bg-destructive/5` + `text-destructive` for a parked one,
`text-muted-foreground` for the id and attempt counter, `tabular-nums` on
counts so they do not jitter, `cn()` for every className merge.
- ARIA: put ONE stable sentence per phase in a `role="status"` element
("Draining the queue…", "3 changes waiting") and keep the per-item churn out
of it — a live region that re-announces on every row transition floods a
screen reader. The queue itself is a real `ul` with an
`aria-label` naming the count. State every row with an icon shape AND a
word ("queued", "sending", "failed"), never colour alone.
- Keyboard: everything is ordinary buttons, so Enter and Space activate them
natively and Tab order is the DOM order. Never put the native `disabled`
attribute on a control whose enabled-ness flips under the user — "Retry
failed" goes inert the instant the last failure clears, and the browser
would blur it to `body`, restarting the next Tab from the top of the page.
Use `aria-disabled` plus an early return in the handler, and keep the button
mounted with a changed label rather than removing it.
- Rows leave the list as their write confirms, so keep rows non-focusable (put
Retry / Discard in the card's toolbar, not inside each row) — otherwise
focus lands on `body` the moment the server answers. Spinners carry
`motion-reduce:animate-none`; nothing about the queue depends on motion.
Customization levers
- Retry policy: `shouldRetry` is your error taxonomy. Classify 5xx / 429 /
network drops as retriable and 4xx as permanent; a queue without this
predicate spends its whole budget proving that a malformed body is still
malformed.
- Backoff shape: pass `attempts => backoffDelay(attempts, { baseDelay: 2000,
jitter: "full" })` from hooks/use-retry to get jitter for free — a shared
outage makes every client reconnect in the same millisecond, and an
unjittered queue re-kills the service that just recovered.
- Ordering strictness: parking a failure keeps later edits flowing, which is
right for independent writes. When writes are causally dependent, run one
hook instance per entity (`key: "queue:doc-41"`) so ordering is guaranteed
inside an entity and independent across them.
- Durability: `storage: null` for a session-only queue, `sessionStorage` for
per-tab, or an IndexedDB adapter behind the same three methods when payloads
are large or binary (localStorage caps out around 5 MB and stores strings
only). One key = one owner; two hooks on the same key overwrite each other,
and two TABS on the same key both drain it — which the idempotency key makes
survivable, but a BroadcastChannel lock makes tidy.
- Reconciliation: type `TResult` as your server response and swap the
optimistic id for the server id inside `onSuccess`.
- Presentation: `items` maps straight onto feedback/offline-indicator's
`actions` (`id`, `label`), so that component renders what this hook owns.
`pending` is the badge number; `isDraining` is the spinner; `failed` is the
only thing that should ever ask the user a question.Concepts
- Optimistic id as idempotency key —
enqueuemints the id, commits and returns the item inside the same event, so the caller can paint an optimistic row immediately; because that same id rides every attempt, the server can recognise a replay of a write it already applied. Any queue that outlives a reload is at-least-once, so this is not an optimisation, it is the price of durability. - Durable commit, not a passive effect — storage is written in the same synchronous block as the state change. An effect-based save is still pending when the user closes the tab on the click that queued the write, which is precisely the failure the hook exists to prevent. Restore treats what it reads as untrusted: bad entries are dropped, an unknown envelope version is ignored, and a write caught mid-flight comes back as
queuedrather than being lost. - Ordered sequential drain — one request at a time, in queue order, because "create then rename" replayed concurrently can land backwards. Each iteration re-reads the store instead of a captured array, so work added during a pass joins that pass and work removed during it simply disappears.
- Parked failure over head-of-line blocking — an item that spends its budget, or whose error
shouldRetrycalls permanent, becomesfailedand is stepped over. A strictly ordered queue would let one malformed request freeze every later edit forever; parking keeps the rest flowing and turns the bad write into a question the user can answer with retry or discard. - Injected connectivity and injected instant —
onlineandnow()both arrive as options. Nothing is sent while the link is down (that request is a guaranteed failure), the flag is re-read on every iteration so a mid-pass drop stops the loop, and the clock is never read during render, so SSR and hydration agree and a throttled background tab still sees real deadlines rather than a decremented lie. - Refusal as a value —
drain()never rejects. A second call while a pass is live resolvesbusyinstead of quietly opening a second one, an empty or fully backed-off queue resolvesidle, and a call with no link resolvesofflinebefore a request is built — so the call site can say what happened without a try/catch.
useFormPersist
A form-draft hook: debounced writes into a versioned storage envelope, an explicit restore offer at mount, and a one-call clear after a successful submit.
useDragDropFiles
File drag and drop as props to spread — a dragenter/dragleave depth counter nested children cannot flicker, an honest drag-time validity preview, dropped folders walked through the FileSystem entry API, and every refusal itemised with a typed reason.