{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-optimistic",
  "title": "useOptimistic",
  "description": "Keyed optimistic updates for plain props and event handlers: props always reclaim a stale overlay so counters cannot drift, sync throws become rejections, repeat clicks on a pending key are refused, out-of-order resolves lose, and failures roll back with a readable error.",
  "files": [
    {
      "path": "src/registry/hooks/use-optimistic.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * One optimistic write, handed to `onAction` (and back to `onError` if it\n * fails). `previous` is the *server* value the optimistic value was derived\n * from — not necessarily what was on screen, because a previous confirmed\n * write may still be showing.\n */\nexport interface OptimisticChange<TValue> {\n  key: string\n  /** The value rendered immediately, before the server has been asked. */\n  next: TValue\n  /** The server value `next` was derived from; also the rollback target. */\n  previous: TValue\n}\n\n/** How one key currently reads. Returned by `get(key)`. */\nexport interface OptimisticEntry<TValue> {\n  /** What to render: the optimistic value while it is still valid, else the server value. */\n  value: TValue\n  /** True while this key has an action in flight — independent of whose value is winning. */\n  pending: boolean\n  /** Last failure for this key, or null. Cleared by the next `mutate`, by `reset`, or when the server value moves on. */\n  error: Error | null\n}\n\nexport interface UseOptimisticOptions<TValue> {\n  /**\n   * The server-owned values, keyed by id. This is the source of truth: the\n   * moment a key's value here changes, that key's optimistic overlay is\n   * discarded and props take over again (see the hook docblock).\n   *\n   * Building this record inline every render is fine — only the *values* are\n   * compared, never the record's identity.\n   */\n  values: Readonly<Record<string, TValue>>\n  /**\n   * Performs the real write. Called synchronously from `mutate`, already\n   * wrapped so that a **synchronous throw becomes a rejection** — a handler\n   * that throws before its first `await` can never leave the key stuck pending.\n   *\n   * - resolve with nothing → the optimistic value stands until props catch up;\n   * - resolve with a value → that value is adopted as confirmed (use it when\n   *   the server answers with the authoritative number);\n   * - reject → the key rolls back to `change.previous` and `error` is set.\n   */\n  onAction: (change: OptimisticChange<TValue>) => void | TValue | Promise<void | TValue>\n  /**\n   * Called on rejection, after the rollback is committed. Use it for toasts\n   * and logging: unlike `get(key).error`, it still fires for a failure whose\n   * row was reclaimed by fresh props in the meantime.\n   */\n  onError?: (error: Error, change: OptimisticChange<TValue>) => void\n  /**\n   * How \"the server value changed\" is decided. Defaults to `Object.is`.\n   *\n   * **Object-shaped values need this.** A parent that rebuilds its item\n   * objects every render hands over a new reference every time, so the default\n   * comparison would treat every render as a server change and throw the\n   * optimistic value away before it is ever seen. Pass a field comparison\n   * (`(a, b) => a.votes === b.votes && a.voted === b.voted`) in that case.\n   */\n  equals?: (a: TValue, b: TValue) => boolean\n}\n\nexport interface UseOptimisticResult<TValue> {\n  /** Effective values for every key in `options.values` — optimistic where one is live, server everywhere else. */\n  values: Record<string, TValue>\n  /** Live failures, keyed. Only contains keys whose server value has not moved on since the failure. */\n  errors: Record<string, Error>\n  /** Keys with an action in flight, in `Object.keys(values)` order. */\n  pendingKeys: string[]\n  /** `pendingKeys.length > 0` — for one global \"saving…\" affordance. */\n  isPending: boolean\n  /** Value + pending + error for one key. Reads props for unknown or reclaimed keys. */\n  get: (key: string) => OptimisticEntry<TValue>\n  /**\n   * Applies an optimistic value and runs `onAction`. Returns false — and does\n   * nothing at all — when the key already has an action in flight, or when the\n   * key is not present in `values`.\n   */\n  mutate: (key: string, next: TValue | ((current: TValue) => TValue)) => boolean\n  /**\n   * Drops the overlay (value + error) for one key, or for every key when\n   * called with no argument, and abandons any in-flight action for those keys:\n   * a late resolve from an abandoned action is ignored, and the key accepts\n   * `mutate` again immediately.\n   */\n  reset: (key?: string) => void\n}\n\n/**\n * Shared \"no overlays\" record. Slot records are always replaced, never mutated,\n * so one frozen empty object can seed every instance — and the ref and the\n * state it renders through start out identical rather than merely equal.\n */\nconst EMPTY_SLOTS = Object.freeze({}) as Record<string, never>\n\n/** Internal overlay for one key. Absent = \"props own this key\". */\ntype Slot<TValue> = {\n  /** The server value this overlay was derived from. Staleness is decided against this. */\n  base: TValue\n  value: TValue\n  pending: boolean\n  error: Error | null\n}\n\nfunction toError(cause: unknown): Error {\n  if (cause instanceof Error) return cause\n  if (typeof cause === \"string\") return new Error(cause)\n  return new Error(\"The optimistic action rejected with a non-Error value.\")\n}\n\nfunction readSlot<TValue>(\n  values: Readonly<Record<string, TValue>>,\n  slots: Record<string, Slot<TValue>>,\n  key: string,\n  equals: (a: TValue, b: TValue) => boolean,\n): OptimisticEntry<TValue> {\n  const slot = slots[key]\n  const serverValue = values[key]\n  if (!slot) return { value: serverValue, pending: false, error: null }\n  // Props reclaim: the overlay describes a server value that no longer exists,\n  // so it is an opinion about the past. `pending` survives because it is a fact\n  // about a request, not about a value.\n  if (!equals(slot.base, serverValue)) {\n    return { value: serverValue, pending: slot.pending, error: null }\n  }\n  return { value: slot.value, pending: slot.pending, error: slot.error }\n}\n\n/**\n * Renders a write before the server has confirmed it, and takes it back\n * correctly when the server disagrees — for a **keyed collection**, driven by\n * plain props and plain event handlers.\n *\n * This is not React's built-in `useOptimistic`. That one is tied to Actions:\n * the optimistic value exists only for the lifetime of the transition that\n * produced it, and the instant that transition ends React snaps back to the\n * state you passed in — with no error surface, no per-key in-flight guard and\n * no way to keep the value while the refetch is still on the wire. This hook\n * is the other trade: no transition required, and the overlay is retired by a\n * *value* change rather than by a *timing* event.\n *\n * - **Props always reclaim.** Every overlay records the server value it was\n *   derived from (`base`). The comparison happens at read time, so the moment\n *   the incoming value differs — even if it differs from the optimistic value\n *   too — props win. A counter can therefore never drift: optimistic `97 → 98`\n *   followed by a server truth of `105` renders 105, never 98 and never 106.\n * - **A synchronous throw is still a rejection.** `onAction` runs inside a\n *   `new Promise(resolve => resolve(...))` executor. `Promise.resolve(fn())`\n *   would not do: the exception escapes before `Promise.resolve` ever sees the\n *   value, and the key stays pending forever.\n * - **Per-key de-duplication through a ref.** The in-flight set is a `Set` in a\n *   ref, written synchronously inside `mutate`. State would not work: a burst\n *   of clicks in one task all read the same pre-render state and all get\n *   through. Different keys never block each other.\n * - **Out-of-order resolves lose.** Each attempt captures a per-key sequence\n *   number; on settle it must still be the newest or it is dropped entirely —\n *   it neither writes state nor releases the in-flight guard it no longer owns.\n * - **Unmount safe.** `aliveRef` is set true inside the mount effect body (not\n *   merely cleared in cleanup, which would leave StrictMode's second mount\n *   believing it is dead) and re-checked after every await.\n *\n * Scope: it overlays values on keys that already exist. Optimistically\n * inserting or removing rows is a different problem — `mutate` on an unknown\n * key is a no-op that returns false.\n */\nexport function useOptimistic<TValue>(options: UseOptimisticOptions<TValue>): UseOptimisticResult<TValue> {\n  const { values, equals = Object.is } = options\n\n  // The ref is the source of truth and is always current; state exists to\n  // render it. Every write goes through `commit`, so the two never diverge.\n  const slotsRef = React.useRef<Record<string, Slot<TValue>>>(EMPTY_SLOTS)\n  const [slots, setSlots] = React.useState<Record<string, Slot<TValue>>>(EMPTY_SLOTS)\n\n  const valuesRef = React.useRef(values)\n  const equalsRef = React.useRef(equals)\n  const onActionRef = React.useRef(options.onAction)\n  const onErrorRef = React.useRef(options.onError)\n  React.useEffect(() => {\n    valuesRef.current = values\n    equalsRef.current = equals\n    onActionRef.current = options.onAction\n    onErrorRef.current = options.onError\n  })\n\n  /** Synchronous double-submit guard. A burst of clicks in one task sees this immediately; state would still be last render's. */\n  const inFlightRef = React.useRef<Set<string>>(new Set())\n  /** Per-key attempt counter. A settle that is no longer the newest attempt is discarded. */\n  const seqRef = React.useRef<Map<string, number>>(new Map())\n  const aliveRef = React.useRef(false)\n\n  React.useEffect(() => {\n    aliveRef.current = true\n    return () => {\n      aliveRef.current = false\n    }\n  }, [])\n\n  const commit = React.useCallback((next: Record<string, Slot<TValue>>) => {\n    slotsRef.current = next\n    setSlots(next)\n  }, [])\n\n  const mutate = React.useCallback(\n    (key: string, next: TValue | ((current: TValue) => TValue)): boolean => {\n      const server = valuesRef.current\n      if (!(key in server)) return false\n      if (inFlightRef.current.has(key)) return false\n\n      const isSame = equalsRef.current\n      const base = server[key]\n      const current = readSlot(server, slotsRef.current, key, isSame).value\n      const value = typeof next === \"function\" ? (next as (current: TValue) => TValue)(current) : next\n\n      const seq = (seqRef.current.get(key) ?? 0) + 1\n      seqRef.current.set(key, seq)\n      inFlightRef.current.add(key)\n\n      // Drop overlays that props already reclaimed, so a long-lived list does\n      // not accumulate one dead slot per row ever touched.\n      const nextSlots: Record<string, Slot<TValue>> = {}\n      for (const other of Object.keys(slotsRef.current)) {\n        if (other === key) continue\n        const slot = slotsRef.current[other]\n        if (slot.pending || isSame(slot.base, server[other])) nextSlots[other] = slot\n      }\n      nextSlots[key] = { base, value, pending: true, error: null }\n      commit(nextSlots)\n\n      const change: OptimisticChange<TValue> = { key, next: value, previous: base }\n\n      /** True only for the attempt that still owns the key; it also releases the guard. */\n      const claim = () => {\n        if (seqRef.current.get(key) !== seq) return false\n        inFlightRef.current.delete(key)\n        return aliveRef.current\n      }\n\n      // A Promise executor, not `Promise.resolve(onAction(change))`: an\n      // `onAction` that throws synchronously must become a rejection here, or\n      // it escapes the click handler and strands the key on pending.\n      new Promise<void | TValue>(resolve => {\n        resolve(onActionRef.current(change))\n      }).then(\n        confirmed => {\n          if (!claim()) return\n          const slot = slotsRef.current[key]\n          if (!slot) return\n          commit({\n            ...slotsRef.current,\n            // `undefined` means \"no opinion, keep what is on screen\"; any other\n            // resolved value is the server's own answer and is adopted now.\n            [key]: { ...slot, value: confirmed === undefined ? slot.value : confirmed, pending: false, error: null },\n          })\n        },\n        (cause: unknown) => {\n          const error = toError(cause)\n          if (!claim()) return\n          const slot = slotsRef.current[key]\n          // Roll back to the server value this attempt started from. A silent\n          // revert reads as \"my click did nothing\" and gets clicked again, so\n          // the error rides along until the next attempt or a props change.\n          if (slot) commit({ ...slotsRef.current, [key]: { base: slot.base, value: slot.base, pending: false, error } })\n          onErrorRef.current?.(error, change)\n        },\n      )\n\n      return true\n    },\n    [commit],\n  )\n\n  const reset = React.useCallback(\n    (key?: string) => {\n      if (key === undefined) {\n        for (const known of seqRef.current.keys()) seqRef.current.set(known, (seqRef.current.get(known) ?? 0) + 1)\n        inFlightRef.current.clear()\n        if (Object.keys(slotsRef.current).length > 0) commit(EMPTY_SLOTS)\n        return\n      }\n      seqRef.current.set(key, (seqRef.current.get(key) ?? 0) + 1)\n      inFlightRef.current.delete(key)\n      if (!(key in slotsRef.current)) return\n      const nextSlots = { ...slotsRef.current }\n      delete nextSlots[key]\n      commit(nextSlots)\n    },\n    [commit],\n  )\n\n  const effective: Record<string, TValue> = {}\n  const errors: Record<string, Error> = {}\n  const pendingKeys: string[] = []\n  for (const key of Object.keys(values)) {\n    const entry = readSlot(values, slots, key, equals)\n    effective[key] = entry.value\n    if (entry.error) errors[key] = entry.error\n    if (entry.pending) pendingKeys.push(key)\n  }\n\n  return {\n    values: effective,\n    errors,\n    pendingKeys,\n    isPending: pendingKeys.length > 0,\n    get: (key: string) => readSlot(values, slots, key, equals),\n    mutate,\n    reset,\n  }\n}\n\nexport default useOptimistic\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}