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.