Nakodo is a Next.js app over 25 Postgres tables with 76 public pages in front of the login. Its src/hooks directory holds this:
$ wc -l src/hooks/*
18 src/hooks/use-action.ts
50 src/hooks/use-confirm.tsx
68 total
There are five other functions in the codebase whose names begin with use, and all five sit in the file of the feature that needs them: usePlanChange and useUpgrade next to the plan switcher, useShares next to the campaign shares editor, useSaveFirst next to the context it reads, useIlloId next to the illustrations. The two in src/hooks are the two that are used by unrelated screens, which is the whole rule for what goes there.
The confirm hook
export type Ask = {
title: string;
text: string;
action: string; // the confirm button
cancel?: string;
destructive?: boolean;
run: () => void;
};
// One confirmation dialog per component, kept filled while it animates out.
// Render `dialog` anywhere in the component and call `confirm` to open it.
export function useConfirm() {
const [ask, setAsk] = useState<Ask | null>(null);
const [open, setOpen] = useState(false);
const confirm = (a: Ask) => {
setAsk(a);
setOpen(true);
};
const dialog = (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{ask?.title}</AlertDialogTitle>
<AlertDialogDescription>{ask?.text}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{ask?.cancel ?? "Cancel"}</AlertDialogCancel>
<AlertDialogAction variant={ask?.destructive ? "destructive" : "default"} onClick={() => ask?.run()}>
{ask?.action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
return [confirm, dialog] as const;
}
The usual shape for this hook returns a promise, so the caller can write if (await confirm(...)). This one takes the work as a field of the question.
That is not a style preference, it follows from what the dialog does on the click. Radix's AlertDialogAction closes the dialog, so by the time the work starts there is no dialog left to put a spinner in, no place to show an error from it, and nothing to disable. A promise-returning version only earns its keep if the dialog stays open while the work runs, and then you need a pending state, an error slot and a decision about Escape mid-flight. Here the work runs after the dialog has gone, and its result arrives as a toast, so the dialog has one job: get a yes.
The two state variables are also deliberate. ask is never cleared. Setting open to false starts Radix's exit animation with the content still mounted, and blanking the title at that moment would fade out an empty box. So the hook keeps the last question until the next one replaces it.
Twelve questions, nine of them on one screen
$ grep -rc "confirm({" src/app/\(app\)/campaigns/\[id\]/outreach/review.tsx \
src/app/\(app\)/campaigns/\[id\]/header.tsx \
src/app/\(app\)/campaigns/\[id\]/results-view.tsx \
src/app/\(app\)/settings/plan/switcher.tsx
review.tsx:9
header.tsx:1
results-view.tsx:1
switcher.tsx:1
Four components, twelve questions. The outreach review screen holds nine of them because it is the screen where everything is reversible until it is not: do not send this one, rewrite these four, stop this conversation, reopen that one. The other three are one each: delete a campaign, contact the creators you have picked, and change plan.
The plan one is the only question whose text is computed rather than written:
confirm({
title: cancel ? `Cancel ${currentName}?` : later ? `Move to ${PLANS[id].name} on ${renews}?` : `Move to ${PLANS[id].name}?`,
text: later ? `You keep ${currentName} until ${renews}...` : downgradeEffects(id),
action: cancel ? `Cancel ${currentName}` : `Move to ${PLANS[id].name}`,
cancel: `Keep ${currentName}`,
run: () => change(id),
});
A downgrade is confirmed in the app rather than in Stripe's portal, so this dialog is the only place the consequences are spelled out. The plans themselves are public on pricing; what this dialog adds is what happens to the campaigns you already have.
The action hook
// Runs a Server Action in a transition and shows its result as a toast.
// `onOk` follows a success: clearing a selection, moving on to the next one.
export function useAction() {
const [pending, start] = useTransition();
const run = <T extends object>(action: () => Promise<ActionResult<T>>, onOk?: (r: ActionOk<T>) => void) =>
start(async () => {
const r = await action();
notify(r);
if (r.ok) onOk?.(r);
});
return [pending, run] as const;
}
Eighteen lines, imported by 13 files, called at 22 sites. The thing that makes it worth having is one React 19 behaviour: startTransition accepts an async function and keeps the transition pending until that function's promise settles. So pending covers the network round trip, the Server Action, the revalidation and the re-render, and every button in the app can be disabled by the same boolean without any component tracking its own request.
notify is four lines of sonner wrapping, with one detail worth stating: a result that carries neither an error nor a message shows nothing at all.
if (r.error) toast.error(r.error, { action });
else if (r.message) toast.success(r.message, { action });
Silence is the success state for an action whose effect is visible on the page. A toast saying "Saved" over a list that just changed in front of you is noise, and the actions that do produce a toast are the ones whose effect is somewhere else.
The two hooks meet in the call sites, and the nesting is honest about what it costs:
confirm({
title: "Delete this campaign?",
text: "This removes the brief, results, conversations and drafts for this campaign. It can't be undone.",
action: "Delete",
destructive: true,
run: () => run(() => deleteCampaignAction(campaignId)),
});
A callback inside a callback inside an object. That reads worse than if (await confirm(...)) run(...) and it is the price of not keeping the dialog open.
Neither hook reaches the public site
The landing page's JavaScript is public, so this is checkable:
$ curl -s https://nakodo.app/ -o home.html
$ for c in $(grep -o '/_next/static/immutable/chunks/[a-zA-Z0-9._/-]*\.js' home.html | sort -u); do
curl -s "https://nakodo.app$c" -o "chunk-$(basename $c)"; done
$ grep -l -E 'AlertDialog|alert-dialog' chunk-* | wc -l
0
Seventeen chunks and not one of them carries the dialog. Nothing on the public pages asks a question that needs confirming: the four components that do are all behind the login. And inside the app, a closed Radix AlertDialog renders no DOM, so the four components that always return dialog from this hook are paying for two booleans each.
What I would change
-
ask?.run()andask?.titleuse optional chaining for a state that cannot happen: the dialog only opens aftersetAsk. It reads like a maybe and is really a never. - The three destructive questions would be better with the dialog held open and its button disabled until the work finishes, which is a different hook rather than a flag on this one. Deleting a campaign is the one case where "it closed and a toast appeared later" is not quite enough.
- Neither hook has a test, and nothing in this repo renders React in a test: 62 test files run under
tsx --testand zero of them import a renderer. Both hooks are small enough that the test would mostly assert Radix's behaviour, which is an argument for leaving it, not a good one.
If you want to see what the app is confirming before it does it, how it works is the overview, and the guides index is the part of the site that explains the decisions those dialogs are guarding.
Top comments (0)