A plain <Link> in the App Router gives the user nothing to look at between the click and the next screen. On a fast route nobody notices. On a route whose layout has to authenticate you and load your account first, the click looks dead for a second or two, and the honest user response to a dead click is to click again.
The usual answer is loading.tsx, and it does not cover this gap. A loading file only renders once the routing boundary it belongs to has been reached, which means after the target segment's layout has resolved. Leaving a practice session for the dashboard means waiting on the dashboard layout's session and user lookup before any loading UI of the destination exists. The window with no feedback is exactly the window loading.tsx cannot reach into.
useLinkStatus reports that window, and it reports it on the element the user is already looking at: the link they clicked.
Three pieces, all of them tiny
The hook only works inside a <Link> descendant, which makes it awkward to use directly and easy to wrap. We have three wrappers.
A spinner that exists only while its link is navigating:
export function LinkSpinner({ size = 16, className }: { size?: number; className?: string }) {
const { pending } = useLinkStatus();
if (!pending) return null;
return <HugeiconsIcon icon={Loading03Icon} size={size} className={cn('animate-spin', className)} aria-hidden />;
}
A swap, for cases where the label itself should change:
export function LinkPending({ children, pending }: { children: React.ReactNode; pending: React.ReactNode }) {
const { pending: isPending } = useLinkStatus();
return <>{isPending ? pending : children}</>;
}
And the one that gets used: a button that navigates and reports its own progress, as a drop-in for <Button asChild><Link/></Button>.
<NavButton
href="/dashboard/games"
variant="ghost"
size="sm"
aria-label="Back to Games"
icon={<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />}
pendingLabel="Exiting…"
>
<span className="hidden sm:inline">Back to Games</span>
</NavButton>
Inside, the pending branch does three things at once: the leading icon becomes a spinner, the label becomes pendingLabel if one was given, and the trailing icon is dropped so the row does not grow while it is busy.
const { pending } = useLinkStatus();
return (
<>
{pending ? <HugeiconsIcon icon={Loading03Icon} size={16} className="animate-spin" aria-hidden /> : icon}
<span aria-live="polite" className="inline-flex items-center gap-2">
{pending && pendingLabel != null ? pendingLabel : children}
</span>
{!pending && trailingIcon}
</>
);
Two details that were not obvious
The live region. A spinner is a visual-only signal. Swapping "Back to Games" for "Exiting…" inside an aria-live="polite" region means the change is announced rather than merely seen, so a screen reader user gets the same "yes, that worked" the sighted user gets from the spin. The spinner itself is aria-hidden, because announcing a spinner tells you nothing the label change has not already said.
That wrapper is a flex row for a reason. The first version used a plain <span> and any icon passed as a child, rather than through icon or trailingIcon, broke onto its own line. Tailwind's preflight sets svg { display: block }, so an inline SVG inside an inline wrapper stops behaving like text. inline-flex items-center gap-2 puts it back on the baseline. It is a two-class fix for a bug that looks like a React problem and is a CSS reset.
Where it is worth using
These three wrappers are imported by exactly five files, and every one of them sits behind sign-in: the shell around a practice session, the screen a finished session lands on, the shell around an assessment centre exercise, the dashboard, and the empty-state hero a brand new account sees. Eight call sites, three distinct pending labels: five say "Loading…", one says "Loading games…", and the two shells wrapped around work in progress say "Exiting…", because leaving mid-session is the one navigation where the user may be worried about what happens to what they are abandoning.
That is the whole heuristic: pending feedback on an instant link is noise. The prefetched links across our public pages, the provider catalogue or a test format page, resolve fast enough that a spinner on them would only ever flash, and none of them use these components. The buttons that need one are the buttons crossing into a layout that authenticates you first, which is why all five files are inside the signed-in app rather than on the pages describing the mock interview or the assessment centre exercises.
The general version of the rule: find the links that cross a layout doing input or output before it renders, and give exactly those their own pending state. For everything else, loading.tsx was already enough.
Top comments (0)