Optimistic Queue
AIA list whose archive action applies optimistically — the row collapses the instant you click, before the request resolves. Success drops it; failure rolls it back, expanding it right into place. The trick: rows are never unmounted on the optimistic step, only after the request confirms, so the exit animation can reverse mid-flight with no re-mount gymnastics. A live queue tracks in-flight actions. Keyboard-accessible and monochrome.
- Your deployment is liveVercel
- 3 issues assigned to youLinear
- Re: flaky test in CIGitHub
- Dana left 2 commentsFigma
Archiving fails ~40% — watch it roll back.
components/ui/optimistic-queue.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* OptimisticQueue — a list whose "archive" action applies *optimistically*:
* the row collapses away the instant you click, before the request resolves.
* If the request succeeds the row is dropped; if it fails, it rolls back —
* expanding right back into place.
*
* <OptimisticQueue
* items={items}
* getId={(i) => i.id}
* getLabel={(i) => i.title}
* onArchive={async (i) => { await api.archive(i.id); }} // throw to fail
* >
* {(i) => <>…row content…</>}
* </OptimisticQueue>
*
* The hard part is the **exit animation surviving a rollback**. The trick: a row
* is never unmounted on the optimistic step — only after the request *confirms*.
* "Removing" just animates `grid-template-rows` to `0fr` (the measure-free
* height collapse); a failure animates it back to `1fr`. Because the element
* stays mounted the whole time, the collapse can reverse mid-flight without any
* of the usual re-mount/exit-node gymnastics. A small live queue reflects each
* in-flight action. Keyboard-accessible, monochrome, reduced-motion aware.
*/
type RowStatus = "idle" | "removing" | "failed";
interface QueueEntry {
key: string;
label: string;
state: "pending" | "success";
}
export interface OptimisticQueueProps<T> {
/** Initial items. The component owns the list from here (uncontrolled). */
items: T[];
getId: (item: T) => string;
/** Human label used for the queue + a11y announcements. */
getLabel: (item: T) => string;
/** Perform the real archive. Resolve to confirm, throw/reject to roll back. */
onArchive: (item: T) => Promise<void>;
/** Row content. */
children: (item: T) => React.ReactNode;
actionLabel?: string;
className?: string;
}
export function OptimisticQueue<T>({
items,
getId,
getLabel,
onArchive,
children,
actionLabel = "Archive",
className,
}: OptimisticQueueProps<T>) {
// The component owns the list — rows leave it only on a *confirmed* success.
const [list, setList] = React.useState<T[]>(items);
const [status, setStatus] = React.useState<Record<string, RowStatus>>({});
const [queue, setQueue] = React.useState<QueueEntry[]>([]);
const seq = React.useRef(0);
const setRow = (id: string, s: RowStatus | null) =>
setStatus((prev) => {
if (s === null) {
const next = { ...prev };
delete next[id];
return next;
}
return { ...prev, [id]: s };
});
const dismiss = (key: string) =>
setQueue((q) => q.filter((e) => e.key !== key));
const archive = React.useCallback(
async (item: T) => {
const id = getId(item);
const label = getLabel(item);
const key = `${id}-${(seq.current += 1)}`;
// Optimistic step: collapse the row and enqueue a pending action.
setRow(id, "removing");
setQueue((q) => [...q, { key, label, state: "pending" }]);
try {
await onArchive(item);
// Confirmed — now it's safe to unmount the (already-collapsed) row.
setQueue((q) =>
q.map((e) => (e.key === key ? { ...e, state: "success" } : e)),
);
setList((l) => l.filter((x) => getId(x) !== id));
setRow(id, null);
window.setTimeout(() => dismiss(key), 1600);
} catch {
// Rollback — the row is still mounted, so it just expands back.
setRow(id, "failed");
dismiss(key);
}
},
[getId, getLabel, onArchive],
);
const keep = (id: string) => setRow(id, "idle");
const pending = queue.filter((e) => e.state === "pending").length;
return (
<div className={cn("w-full", className)}>
<ul className="space-y-1.5" data-slot="optimistic-queue">
{list.map((item) => {
const id = getId(item);
const st = status[id] ?? "idle";
const removing = st === "removing";
const failed = st === "failed";
return (
<li key={id} data-status={st}>
{/* Height collapse via the measure-free grid-rows technique. */}
<div
className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
style={{ gridTemplateRows: removing ? "0fr" : "1fr" }}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"flex items-center gap-3 rounded-xl border bg-white px-3.5 py-3 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none dark:bg-zinc-900",
removing
? "translate-x-2 opacity-0"
: "translate-x-0 opacity-100",
failed
? "border-zinc-950/40 dark:border-zinc-50/40"
: "border-zinc-200 dark:border-zinc-800",
)}
>
<div className="min-w-0 flex-1">{children(item)}</div>
{failed ? (
<div className="flex shrink-0 items-center gap-1.5">
<span className="mr-1 hidden font-mono text-xs text-zinc-500 sm:inline dark:text-zinc-400">
Couldn't archive
</span>
<button
type="button"
onClick={() => archive(item)}
className="rounded-md border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-950 transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800 dark:focus-visible:ring-zinc-50/50"
>
Retry
</button>
<button
type="button"
onClick={() => keep(id)}
className="rounded-md px-2.5 py-1 text-xs text-zinc-500 transition-colors hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:text-zinc-400 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-50/50"
>
Keep
</button>
</div>
) : (
<button
type="button"
onClick={() => archive(item)}
disabled={removing}
aria-label={`${actionLabel} ${getLabel(item)}`}
className="shrink-0 rounded-md border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-50/50"
>
{actionLabel}
</button>
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
{list.length === 0 ? (
<p className="rounded-xl border border-dashed border-zinc-200 px-3.5 py-8 text-center text-sm text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
All caught up.
</p>
) : null}
{/* Live action queue. */}
<div
aria-live="polite"
className={cn("mt-3 space-y-1.5", queue.length === 0 && "sr-only")}
>
{pending > 0 ? (
<div className="px-0.5 font-mono text-[11px] tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{pending} in flight
</div>
) : null}
{queue.map((entry) => (
<QueueToast key={entry.key} entry={entry} />
))}
</div>
</div>
);
}
function QueueToast({ entry }: { entry: QueueEntry }) {
const [shown, setShown] = React.useState(false);
React.useEffect(() => {
const raf = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(raf);
}, []);
const success = entry.state === "success";
return (
<div
data-state={shown ? "in" : "out"}
className={cn(
"flex items-center gap-2.5 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400",
"translate-y-1 opacity-0 transition duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] data-[state=in]:translate-y-0 data-[state=in]:opacity-100",
"motion-reduce:translate-y-0 motion-reduce:transition-none",
)}
>
{success ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3.5 shrink-0 text-zinc-950 dark:text-zinc-50"
>
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<span
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-950 motion-reduce:animate-none dark:border-zinc-800 dark:border-t-zinc-50"
/>
)}
<span className="truncate">
{success ? "Archived" : "Archiving"} {entry.label}
</span>
</div>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/optimistic-queue.json1. Install dependencies
Terminal
npm install clsx tailwind-merge2. Copy the source into your project
components/ui/optimistic-queue.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* OptimisticQueue — a list whose "archive" action applies *optimistically*:
* the row collapses away the instant you click, before the request resolves.
* If the request succeeds the row is dropped; if it fails, it rolls back —
* expanding right back into place.
*
* <OptimisticQueue
* items={items}
* getId={(i) => i.id}
* getLabel={(i) => i.title}
* onArchive={async (i) => { await api.archive(i.id); }} // throw to fail
* >
* {(i) => <>…row content…</>}
* </OptimisticQueue>
*
* The hard part is the **exit animation surviving a rollback**. The trick: a row
* is never unmounted on the optimistic step — only after the request *confirms*.
* "Removing" just animates `grid-template-rows` to `0fr` (the measure-free
* height collapse); a failure animates it back to `1fr`. Because the element
* stays mounted the whole time, the collapse can reverse mid-flight without any
* of the usual re-mount/exit-node gymnastics. A small live queue reflects each
* in-flight action. Keyboard-accessible, monochrome, reduced-motion aware.
*/
type RowStatus = "idle" | "removing" | "failed";
interface QueueEntry {
key: string;
label: string;
state: "pending" | "success";
}
export interface OptimisticQueueProps<T> {
/** Initial items. The component owns the list from here (uncontrolled). */
items: T[];
getId: (item: T) => string;
/** Human label used for the queue + a11y announcements. */
getLabel: (item: T) => string;
/** Perform the real archive. Resolve to confirm, throw/reject to roll back. */
onArchive: (item: T) => Promise<void>;
/** Row content. */
children: (item: T) => React.ReactNode;
actionLabel?: string;
className?: string;
}
export function OptimisticQueue<T>({
items,
getId,
getLabel,
onArchive,
children,
actionLabel = "Archive",
className,
}: OptimisticQueueProps<T>) {
// The component owns the list — rows leave it only on a *confirmed* success.
const [list, setList] = React.useState<T[]>(items);
const [status, setStatus] = React.useState<Record<string, RowStatus>>({});
const [queue, setQueue] = React.useState<QueueEntry[]>([]);
const seq = React.useRef(0);
const setRow = (id: string, s: RowStatus | null) =>
setStatus((prev) => {
if (s === null) {
const next = { ...prev };
delete next[id];
return next;
}
return { ...prev, [id]: s };
});
const dismiss = (key: string) =>
setQueue((q) => q.filter((e) => e.key !== key));
const archive = React.useCallback(
async (item: T) => {
const id = getId(item);
const label = getLabel(item);
const key = `${id}-${(seq.current += 1)}`;
// Optimistic step: collapse the row and enqueue a pending action.
setRow(id, "removing");
setQueue((q) => [...q, { key, label, state: "pending" }]);
try {
await onArchive(item);
// Confirmed — now it's safe to unmount the (already-collapsed) row.
setQueue((q) =>
q.map((e) => (e.key === key ? { ...e, state: "success" } : e)),
);
setList((l) => l.filter((x) => getId(x) !== id));
setRow(id, null);
window.setTimeout(() => dismiss(key), 1600);
} catch {
// Rollback — the row is still mounted, so it just expands back.
setRow(id, "failed");
dismiss(key);
}
},
[getId, getLabel, onArchive],
);
const keep = (id: string) => setRow(id, "idle");
const pending = queue.filter((e) => e.state === "pending").length;
return (
<div className={cn("w-full", className)}>
<ul className="space-y-1.5" data-slot="optimistic-queue">
{list.map((item) => {
const id = getId(item);
const st = status[id] ?? "idle";
const removing = st === "removing";
const failed = st === "failed";
return (
<li key={id} data-status={st}>
{/* Height collapse via the measure-free grid-rows technique. */}
<div
className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"
style={{ gridTemplateRows: removing ? "0fr" : "1fr" }}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"flex items-center gap-3 rounded-xl border bg-white px-3.5 py-3 transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none dark:bg-zinc-900",
removing
? "translate-x-2 opacity-0"
: "translate-x-0 opacity-100",
failed
? "border-zinc-950/40 dark:border-zinc-50/40"
: "border-zinc-200 dark:border-zinc-800",
)}
>
<div className="min-w-0 flex-1">{children(item)}</div>
{failed ? (
<div className="flex shrink-0 items-center gap-1.5">
<span className="mr-1 hidden font-mono text-xs text-zinc-500 sm:inline dark:text-zinc-400">
Couldn't archive
</span>
<button
type="button"
onClick={() => archive(item)}
className="rounded-md border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-950 transition-colors hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800 dark:focus-visible:ring-zinc-50/50"
>
Retry
</button>
<button
type="button"
onClick={() => keep(id)}
className="rounded-md px-2.5 py-1 text-xs text-zinc-500 transition-colors hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:text-zinc-400 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-50/50"
>
Keep
</button>
</div>
) : (
<button
type="button"
onClick={() => archive(item)}
disabled={removing}
aria-label={`${actionLabel} ${getLabel(item)}`}
className="shrink-0 rounded-md border border-zinc-200 px-2.5 py-1 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-50/50"
>
{actionLabel}
</button>
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
{list.length === 0 ? (
<p className="rounded-xl border border-dashed border-zinc-200 px-3.5 py-8 text-center text-sm text-zinc-500 dark:border-zinc-800 dark:text-zinc-400">
All caught up.
</p>
) : null}
{/* Live action queue. */}
<div
aria-live="polite"
className={cn("mt-3 space-y-1.5", queue.length === 0 && "sr-only")}
>
{pending > 0 ? (
<div className="px-0.5 font-mono text-[11px] tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{pending} in flight
</div>
) : null}
{queue.map((entry) => (
<QueueToast key={entry.key} entry={entry} />
))}
</div>
</div>
);
}
function QueueToast({ entry }: { entry: QueueEntry }) {
const [shown, setShown] = React.useState(false);
React.useEffect(() => {
const raf = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(raf);
}, []);
const success = entry.state === "success";
return (
<div
data-state={shown ? "in" : "out"}
className={cn(
"flex items-center gap-2.5 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400",
"translate-y-1 opacity-0 transition duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] data-[state=in]:translate-y-0 data-[state=in]:opacity-100",
"motion-reduce:translate-y-0 motion-reduce:transition-none",
)}
>
{success ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3.5 shrink-0 text-zinc-950 dark:text-zinc-50"
>
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<span
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin rounded-full border-2 border-zinc-200 border-t-zinc-950 motion-reduce:animate-none dark:border-zinc-800 dark:border-t-zinc-50"
/>
)}
<span className="truncate">
{success ? "Archived" : "Archiving"} {entry.label}
</span>
</div>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Initial items. The component owns the list from here (uncontrolled). |
getId / getLabel | (item: T) => string | — | Stable id per item, and a human label for the queue + a11y. |
onArchive | (item: T) => Promise<void> | — | Perform the real archive. Resolve to confirm the removal, throw/reject to roll it back. |
children | (item: T) => React.ReactNode | — | Render prop for each row's content. |
actionLabel | string | "Archive" | Label for the per-row action button. |
Dependencies
clsxtailwind-merge