modeling link navigation state with useTransition + useOptimistic
nerd snipe: poking through next.js internals showed a clean pattern for modeling link “pending” state without timers, effects, or bespoke cleanup. this post distills that pattern into a tiny, production-friendly api.
tl;dr
- react 18 gives you
useTransition; react 19 addsuseOptimistic. - start a transition on click, flip an optimistic link status to
pending: true, navigate, and let react roll it back to idle when suspense/data settles. - no imperative cleanup. no router event juggling. status is scoped, precise, and aligns with suspense boundaries.
- bonus: this mirrors how next.js implements its internal
<link>+useLinkStatushook (see “how this maps to next.js internals”).
the two primitives
-
useTransition()→[isPending, startTransition].isPendingflips totruewhenstartTransition(fn)runs and returns tofalsewhen the transition’s async work (including suspense/data) finishes. -
useOptimistic(base, reducer)→[state, setOptimistic]. enqueue optimistic updates during a transition; when the transition completes, react clears them and the state reverts to the base.
put together:
when a navigation starts, optimistically set
{ pending: true }; when the transition completes, automatically fall back to{ pending: false }. no effect cleanup required.
minimal surface area
we expose a tiny context + hook so descendants can read the current link status:
// lib/link-status-constants.ts
export const IDLE_LINK_STATUS = { pending: false } as const;
export const PENDING_LINK_STATUS = { pending: true } as const;
export type LinkStatus = typeof IDLE_LINK_STATUS | typeof PENDING_LINK_STATUS;// context/link-status-context.tsx
"use client";
import { createContext, useContext } from "react";
import { IDLE_LINK_STATUS } from "@/lib/link-status-constants";
export const LinkStatusContext = createContext(IDLE_LINK_STATUS);
export function useLinkStatus() {
return useContext(LinkStatusContext);
}the custom <link> (the core idea in ~20 lines)
wrap the click in a transition and flip optimistic state:
// components/link.tsx
"use client";
import {
startTransition,
useOptimistic,
type MouseEvent,
type ReactNode,
} from "react";
import { useRouter, usePathname } from "next/navigation";
import { LinkStatusContext } from "@/context/link-status-context";
import {
IDLE_LINK_STATUS,
PENDING_LINK_STATUS,
} from "@/lib/link-status-constants";
export function Link({
href,
replace = false,
scroll = true,
prefetch = true,
children,
className,
onClick,
}: {
href: string;
replace?: boolean;
scroll?: boolean;
prefetch?: boolean;
children: ReactNode;
className?: string;
onClick?: (e: MouseEvent<HTMLAnchorElement>) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const [linkStatus, setOptimisticLinkStatus] = useOptimistic(
IDLE_LINK_STATUS,
(_current, optimistic) => optimistic // pass-through reducer
);
const isModifiedEvent = (e: MouseEvent<HTMLAnchorElement>) =>
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
((e.currentTarget.getAttribute("target") ?? "") !== "" &&
e.currentTarget.getAttribute("target") !== "_self") ||
(e.nativeEvent as any)?.which === 2;
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
onClick?.(e);
if (
e.defaultPrevented ||
isModifiedEvent(e) ||
e.currentTarget.hasAttribute("download")
)
return;
e.preventDefault();
if (pathname === href) return;
startTransition(() => {
setOptimisticLinkStatus(PENDING_LINK_STATUS);
replace
? router.replace(href, { scroll })
: router.push(href, { scroll });
});
};
return (
<LinkStatusContext.Provider value={linkStatus}>
<a
href={href}
onClick={handleClick}
onMouseEnter={() => {
if (prefetch && typeof window !== "undefined") router.prefetch(href);
}}
className={className}
aria-busy={linkStatus.pending || undefined}
data-pending={linkStatus.pending ? "" : undefined}
>
{children}
</a>
</LinkStatusContext.Provider>
);
}notes
- modified clicks (ctrl/cmd/shift, middle-click,
target≠_self) fall back to native navigation — progressive enhancement preserved. - hover prefetch is opt-in and happens only on the client.
- the provider scope is per-link, so sibling links don’t leak state into each other.
consuming the status
because the status is in context, any descendant can react to it:
// components/loading-indicator.tsx
"use client";
import { useLinkStatus } from "@/context/link-status-context";
export function LoadingIndicator() {
const { pending } = useLinkStatus();
return pending ? <span>⏳</span> : null;
}usage:
// page.tsx
"use client";
import { Link } from "@/components/link";
import { LoadingIndicator } from "@/components/loading-indicator";
export default function Home() {
return (
<div className="flex flex-col gap-3 max-w-md mx-auto p-4">
<Link
href="/dashboard"
className="flex items-center justify-between rounded-lg px-4 py-3 bg-blue-500 text-white transition-colors hover:bg-blue-600"
>
<span>go to dashboard</span>
<LoadingIndicator />
</Link>
</div>
);
}how this maps to next.js internals
this implementation intentionally mirrors how next.js wires its <link> and internal useLinkStatus:
- transition-driven pending: next wraps navigation in a react transition and drives a boolean “pending” bit from that lifecycle.
- optimistic flip + automatic rollback:
useOptimisticsets{ pending: true }at transition start and lets react clear it when suspense/data resolves (no cleanup path). - scoped context:
<link>provides a context so any descendant (badges, spinners, aria hints) can readpendingviauseLinkStatus()without prop drilling. - event hygiene: modified clicks and
downloadare respected; default browser behavior is preserved. client-side prefetch triggers on hover. - semantics parity: when the transition finishes (success or handled error), pending clears. consumers should pair this with error boundaries for failure ui.
migration notes
- if/when the
useLinkStatusapi is exposed publicly, you can:- keep your component tree, but import
useLinkStatusfrom next and drop your local context; or - swap your custom
<link>back tonext/linkwhile keeping consumers unchanged (since they just read{ pending }from context).
- keep your component tree, but import
- this post keeps the surface minimal; next’s production
<link>also deals with additional props (prefetchstrategies, locales,rel, etc.). integrate those as needed.
edge cases & gotchas
- same-route clicks: bail early on
pathname === hrefto avoid a phantom pending blip. - global indicators: for a header-level spinner, either lift the provider to a layout boundary or mirror the optimistic flag into a global store inside the transition.
- a11y: pair
aria-busy/data-pendingwith anaria-live="polite"region if you want announcements.