@k8ordo/router

How it works

How @k8ordo/router handles navigation. None of it is needed to use the package, but knowing why it behaves as it does makes it easier to find the cause when something does not.

On this page

Which navigations the router takes

A link click, navigateTo, the browser’s back and forward, and a GET form submission all reach the page as the same navigate event. The router takes that event and intercepts only the navigations whose destination the route table answers.

These four it never takes, whatever the route table says, and leaves to the browser.

  • A reload: it asks for a fresh copy of the page
  • A form submitted with a body (POST): only the server can act on the body
  • A download: it saves a file
  • A change to the fragment alone (what follows #): it moves within the same page

Taking any of them would make nothing happen where the browser would have done the obvious thing. Navigations the browser does not let a page intercept, such as one to another origin, are not taken either.

A GET form carries no body, so it is taken. The submissions @k8ordo/state builds, which only rewrite the query string, come through here too.

finished means the page is on screen

The router does not let an intercepted navigation finish until the new page is on screen. So the finished that navigateTo returns resolves when the page has rendered, not when the URL changed.

Precisely, it resolves after React has committed the new page and before the browser paints it, which also means the new page never shows for a frame at the old scroll position.

What it waits for is the new page’s first commit. When a React.lazy page suspends into a <Suspense> the navigation newly mounted, finished resolves once the fallback is drawn, without waiting for the code.

A state update is not a page change

When only the query string or the history entry’s state changes, the path is that of the page on screen. The router does not treat this as a page change, and intercepts it without loading anything.

Nothing remounts, and neither scroll nor focus moves; this is why changing a search does not jump back to the top. With no render to wait for, finished resolves as soon as the URL changes, and so does the one @k8ordo/state’s update() returns.

The comparison is with the page on screen, not with the address bar. A state update aimed at a page that is still loading counts as a page change: that page still arrives, and the update’s finished waits for it to render.

The next page renders in the background

The new page renders at the priority useDeferredValue gives it, so the previous page stays on screen, and stays usable, until the next one is ready.

It is not a transition because React holds every transition while an async action is pending, until that action ends. As a transition, an action awaiting finished would wait on itself, and a page change would also wait for any unrelated action.

The render is tagged navigation and a kind such as navigation-push, which is what lets a <ViewTransition> animate page changes and nothing else.

Where a new page starts

Once the new page is on screen, the router scrolls to where a page load would have: to the element the fragment names, or to the top when there is none.

The fragment’s element is the one whose id or name attribute matches. The fragment is decoded first, so #%E5%B0%8E%E5%85%A5 finds id="導入", and the page goes to the top when nothing matches.

On the browser’s back and forward, the router leaves scrolling alone, and the browser restores the position it saved.

Focus also goes back to <body>, as on a page load. A state update moves focus no more than it moves the scroll position.

A superseded navigation is abandoned

When the next navigation starts while one is still loading, the earlier one is abandoned, through the browser’s own AbortSignal.

The overtaken navigation’s finished rejects with the abort reason, and its page never reaches the screen, even when its load had already come back.

A React.lazy chunk cannot be cancelled, since a dynamic import takes no AbortSignal: it finishes and is kept for the next visit, while the overtaken page is never shown. Under the framework, the fetch for the next page’s data is cancelled outright.

Build your own host with useInterceptedNavigation

Everything above is the work of one hook, useInterceptedNavigation. <Router> is that hook wired to a route table, and the framework’s runtime is the same hook wired to pages arriving from the server.

The hook takes an object with these functions.

  • claim(url): whether to take this navigation. Interception is only possible during the event, so it answers synchronously.
  • load(url, signal): produces what to render for the URL, as a value or a promise. signal aborts when the navigation is overtaken.
  • apply(value): applies it, as an ordinary update outside any transition.
  • refresh(url): optional. Whether a navigation that keeps the path should still load.
src/article-host.tsx
'use client';

import {
  NavigationGeneration,
  PathnameProvider,
  useInterceptedNavigation,
} from '@k8ordo/router';
import { useDeferredValue, useState } from 'react';
import type { ReactNode } from 'react';

type Props = { initial: ReactNode; pathname: string };

export function ArticleHost({ initial, pathname }: Props) {
  const [latest, setLatest] = useState(initial);
  const { generation } = useInterceptedNavigation<ReactNode>({
    claim: (url) => url.pathname.startsWith('/articles/'),
    load: (url, signal) => loadArticle(url, signal),
    apply: setLatest,
  });
  const shown = useDeferredValue(latest);

  return (
    <PathnameProvider pathname={pathname}>
      <NavigationGeneration value={generation}>
        {shown}
      </NavigationGeneration>
    </PathnameProvider>
  );
}

Render what apply set through useDeferredValue, in the same component that calls the hook. The new page then renders in the background, and generation and finished move in that same commit.

generation is a number that changes only when a new page is on screen. Provided through <NavigationGeneration>, it tells the route table’s error when to let a failure go. <PathnameProvider> gives usePathname its path during a server render and hydration.

When refresh returns true, a navigation that keeps the path loads and applies like a page change, but moves neither scroll nor focus and carries no navigation types. The framework returns true for a page that exports search when the query string changes.

Information

Note

<Router> and the framework’s runtime both provide them themselves, so an app writes them only when it uses this hook directly.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2