Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Where one queued write currently sits. Success is deliberately **not** a status: a write the
 * server confirmed leaves the queue, so `items` is always "the work still outstanding" and never
 * a growing history nobody prunes.
 *
 * - `queued` — waiting for its turn, or parked behind its own backoff deadline (`nextAttemptAt`).
 * - `sending` — the request is on the wire. Exactly one item is ever in this status.
 * - `failed` — the retry budget is spent, or `shouldRetry` judged the error permanent. The item
 *   stays in the queue but the drain skips it, so one poisoned write cannot block the rest. Only
 *   `retry()` or `remove()` moves it.

Installation

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

Prompt

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 keyenqueue mints 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 queued rather 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 shouldRetry calls permanent, becomes failed and 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 instantonline and now() 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 valuedrain() never rejects. A second call while a pass is live resolves busy instead of quietly opening a second one, an empty or fully backed-off queue resolves idle, and a call with no link resolves offline before a request is built — so the call site can say what happened without a try/catch.

On This Page