ui
Optimistic action with undo
Report the action as done, offer Undo for a window, commit when the window closes — and roll back visibly if it fails.
npx shadcn@latest add @hextor/optimistic-toastSource
"use client";
import * as React from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Toaster, createToastManager } from "@/components/ui/toast";
import {
resolveLabels,
type OptimisticToastLabels,
} from "@/registry/hextor/ui/optimistic-toast-labels";
type ToastManagerHandle = ReturnType<typeof createToastManager>;
interface OptimisticToastContextValue {
manager: ToastManagerHandle;
labels: OptimisticToastLabels;
}
const OptimisticToastContext = createContext<OptimisticToastContextValue | null>(null);
export interface OptimisticActionProviderProps {
children: React.ReactNode;
labels?: Partial<OptimisticToastLabels>;
}
/**
* Hosts a dedicated toast manager and stack for optimistic actions. Its own
* manager instance (not the app-wide `toast` singleton from `ui/toast`) so
* an "Undo" toast never gets buried under unrelated notifications, or
* dismissed by an unrelated `toast.close()` elsewhere in the app.
*/
export function OptimisticActionProvider({ children, labels: labelsProp }: OptimisticActionProviderProps) {
const [manager] = useState(() => createToastManager());
const labels = useMemo(() => resolveLabels(labelsProp), [labelsProp]);
const value = useMemo<OptimisticToastContextValue>(() => ({ manager, labels }), [manager, labels]);
return (
<OptimisticToastContext.Provider value={value}>
<Toaster toastManager={manager}>{children}</Toaster>
</OptimisticToastContext.Provider>
);
}
export interface OptimisticActionOptions {
/**
* Phrased as already done — "Issue archived", never "Archiving…". The
* whole point of the pattern is that the UI already reflects the change;
* the toast should not contradict what the screen is showing.
*/
message: string;
undoLabel?: string;
/** How long the undo window stays open, in ms. @default 5000 */
durationMs?: number;
/**
* Runs if the window closes without Undo — the real mutation. The caller
* must have already applied the optimistic change to its own state
* BEFORE calling `run`; this hook owns only the toast, the timer, and this
* deferred commit, never the state itself.
*/
commit: () => void | Promise<void>;
/**
* Puts the UI back exactly as it was. Runs if the user clicks Undo, OR if
* `commit` throws — getting this second path right (silently, reliably,
* every time) is the entire reason this hook exists rather than a plain
* toast-with-a-button.
*/
rollback: () => void | Promise<void>;
/** Shown if `commit` throws. Defaults to a generic, honest failure message. */
errorMessage?: string;
}
export interface UseOptimisticActionResult {
run: (options: OptimisticActionOptions) => void;
}
let nextId = 0;
/**
* `run()` shows the "already done" toast immediately and defers the real
* commit until the undo window closes. The failure path is the point: if
* `commit` rejects, the UI is rolled back and the toast is rewritten in
* place to say so — a silent failure here would leave the screen showing a
* change that never actually happened.
*
* The pending toast's own auto-dismiss timeout is disabled (`timeout: 0`);
* this hook's own `setTimeout` is the single source of truth for when the
* window closes, so there is no race between the toast disappearing on its
* own and this hook trying to update it afterward.
*/
export function useOptimisticAction(): UseOptimisticActionResult {
const ctx = useContext(OptimisticToastContext);
if (!ctx) {
throw new Error("useOptimisticAction must be used within an <OptimisticActionProvider>.");
}
const { manager, labels } = ctx;
const idCounter = useRef(0);
const run = useCallback(
(options: OptimisticActionOptions) => {
const duration = options.durationMs ?? 5000;
// A per-call, per-hook-instance id is enough — this never needs to be
// globally unique across page loads, only unique among toasts alive
// right now.
const id = `optimistic-toast-${nextId++}-${idCounter.current++}`;
let outcome: "pending" | "undone" | "committed" = "pending";
const timer = window.setTimeout(() => {
if (outcome !== "pending") return;
outcome = "committed";
Promise.resolve()
.then(() => options.commit())
.then(() => {
// Success is silent: the optimistic toast already told the user
// it's done, so a second "Saved" toast now would just be noise.
manager.close(id);
})
.catch(() => {
Promise.resolve(options.rollback()).finally(() => {
manager.update(id, {
type: "error",
title: labels.failedTitle,
description: options.errorMessage ?? labels.failedMessage,
timeout: 6000,
actionProps: undefined,
});
});
});
}, duration);
manager.add({
id,
type: "success",
title: options.message,
timeout: 0,
actionProps: {
children: options.undoLabel ?? labels.undo,
onClick: () => {
if (outcome !== "pending") return;
outcome = "undone";
window.clearTimeout(timer);
Promise.resolve(options.rollback()).finally(() => {
manager.update(id, {
type: undefined,
title: labels.undone,
description: undefined,
timeout: 2000,
actionProps: undefined,
});
});
},
},
});
},
[manager, labels],
);
return { run };
}
Docs
The failure path is the entire job. Anyone can show a toast that says "Deleted"; the question is what the screen says when the commit rejects three seconds after you already told the user it worked. Here the optimistic change rolls back visibly and the toast rewrites itself with the reason, so the screen and the server never quietly disagree.
No second toast on success. Confirming something you already confirmed trains people to dismiss toasts without reading them.
Built on Base UI's toast rather than sonner, deliberately: sonner would be a new npm dependency in every consumer that installs this.
Dependencies
lucide-reacttoast