@k8ordo/router

リンクと現在地

ページは表を import しません。リンクもナビゲーションも現在地の問い合わせも、パターンの文字列だけで書きます。このページでは hrefnavigateTo、共有の params を束ねる bindParams、その型の出どころである Register、そして現在地を読むフックを扱います。

href でパスを作る

href(pattern, params?) はパターンと params から具体的なパスを作ります。表は使わないので、どのコンポーネントからでも呼べます。params はパターン文字列から推論され、params を持たないパターンは第 2 引数を取りません。

// src/product-links.tsx
import { href } from '@k8ordo/router';

export function ProductLinks({ id }: { id: string }) {
  return (
    <nav>
      <a href={href('/products')}>All products</a>
      <a href={href('/products/:id', { id })}>This product</a>
    </nav>
  );
}

Register が params の型を持たないとき(<Router> を自分でマウントするアプリ、またはどのスキーマもかからないパターン)、値は stringnumberbigintboolean のどれでも渡せます(型 ParamValue)。どれも綴りが 1 つに決まるからです。値は encodeURIComponent で符号化されるので a/ba%2Fb になり、照合で params を読むときに再びデコードされます(戻るのは文字列です)。

呼び出し結果
href('/products')'/products'
href('/products/:id', { id: 42 })'/products/42'
href('/products/:id', { id: 'a/b' })'/products/a%2Fb'
href('/:locale/products', { locale: 'en' })'/en/products'

戻り値の型はパスの形を保ちます。:param の位置が任意の文字列になったテンプレートリテラル型なので、型付きのパスを受け取る側(@k8ordo/state など)にそのまま渡せます。

import { href } from '@k8ordo/router';

const path: `/products/${string}` = href('/products/:id', { id: '42' });

型を迂回して呼んだ場合、href は実行時にも TypeError で拒みます。

  • ワイルドカードを含むパターン"/:locale/*" is a wildcard — it has no href
  • param の値が無い"/products/:id" needs a value for ":id"
  • オブジェクトなど、URL の綴りを持たない値"/products/:id" got a value for ":id" that has no URL spelling

<Link> が無い理由

Navigation API の下では、素の <a> がすでにクライアント遷移です。ブラウザはリンクのクリックで navigate イベントを送り、ルーターはそのイベントを intercept します。<a> を包むコンポーネントを作っても同じことを書く方法が 2 つになるだけなので、型の検査は href が受け持ちます。

表が答えない pathname へのリンクは intercept されず、ブラウザの通常の文書読み込みになります。ただし表の最後に /* があれば、表はどの pathname にも答えます。そのときホストが配るファイルへのリンクには download 属性を付けてください。ブラウザがクリックの時点でダウンロードだと伝えるので、ルーターは手を出しません。

navigateTo で移動する

navigateTo(pattern, params?, options?)href で作ったパスへ navigation.navigate() で移動します。戻り値はプラットフォームの { committed, finished } そのものです。

import { navigateTo } from '@k8ordo/router';

navigateTo('/products/:id', { id: '42' });
navigateTo('/products/:id', { id: '42' }, { history: 'replace' });
navigateTo('/products');
navigateTo('/products', { history: 'replace' });

オプションは NavigateToOptionshistory だけで、'push'(既定)か 'replace' です。params を持たないパターンでは、オプションが第 2 引数になります。

既定が push なのは、ページの移動は戻るボタンで取り消せるべきだからです。@k8ordo/stateupdate() は逆に replace が既定で、理由も同じです。ページの中身を絞り込む操作を戻るボタンで 1 つずつ戻したくはありません。ページを変えるのは navigateTo、状態を変えるのは update です。 @k8ordo/state

finished は新しいページが画面に出たときに解決します。非同期アクションの中で待てば、移動を待つ間は isPending が立ちます。

// src/open-product-button.tsx
import { navigateTo } from '@k8ordo/router';
import { useTransition } from 'react';

const isAbort = (error: unknown) =>
  error instanceof DOMException && error.name === 'AbortError';

export function OpenProductButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();
  return (
    <button
      disabled={isPending}
      onClick={() => {
        startTransition(async () => {
          try {
            await navigateTo('/products/:id', { id }).finished;
          } catch (error) {
            if (!isAbort(error)) throw error;
          }
        });
      }}
      type="button"
    >
      {isPending ? 'Opening…' : 'Open'}
    </button>
  );
}

ページの切り替えはアクションに加わらないので、@k8ordo/uiButtononAction<form action> の中で待っても、finished はページが画面に出た時点で解決します。無関係な非同期アクションが保留中の間に始まったページの切り替えも、そのアクションを待たずに画面に出ます。イベントハンドラの中で待つこともできます。

別のナビゲーションが追い越すと、finished は abort の理由(名前が AbortErrorDOMException)で reject します。追い越されうる場所で待つコードは、上の例のように abort だけを無視し、それ以外のエラーは投げ直します。 ナビゲーション

Register で表と照合する

params の推論は設定なしで働きます。パターンそのものを実際の表と照合するには、アプリで 1 度だけ Registerroutes を宣言します。

// types/k8ordo-router.d.ts
import type { routes } from '../src/routes';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
  }
}

宣言すると、hrefnavigateTouseParamsuseMatchmatchPath に渡すパターンが表のものに限られます。宣言の前は / で始まる任意の文字列が通ります。

import { href, matchPath } from '@k8ordo/router';

href('/products/:id', { id: '42' });
matchPath('/products/*', '/products/42');

// @ts-expect-error
href('/prodcuts/:id', { id: '42' });

// @ts-expect-error
matchPath('/about/*', '/about/team');

@k8ordo/static@k8ordo/server では、この宣言が .k8ordo/register.gen.ts に生成されます。そこで手書きすると、すでに答えのある問いに 2 つ目の答えを書くことになります。 フレームワーク配下

意味
RegisteredPattern表のすべての leaf パターン(各ページと各 /*。自分の位置にページを持たない接頭辞は含まない)。宣言の前は / で始まる任意の文字列
RegisteredNavigablePatternリンク先にできるパターン(ワイルドカードを除く)。宣言の前は / で始まる任意の文字列
RegisteredParams<P>リンクが受け取る params の型。スキーマのかかるパターンでは、スキーマが型を決めた param はその型、残りはページと同じ文字列。スキーマのかからないパターンでは、どの param も ParamValue
ParamsOf<P>パターンの params。ParamsOf<'/:locale/products/:id'>{ locale: string; id: string }
PathFor<P>パターンに合うパスの型。:param の位置を任意の文字列にしたテンプレートリテラル型
ParamValueURL の綴りが 1 つに決まる値。string | number | bigint | boolean

共有の params を bindParams で束ねる

ロケールやテナントのように、すべてのリンクが繰り返すことになる区間は、関数から供給します。bindParams(source) は、source が返す params を毎回補う hrefnavigateTo を返します。このサイト自身の src/links.ts がそのまま例です。

// src/links.ts
import { bindParams } from '@k8ordo/router';

import { locales } from './i18n';

export const { href, navigateTo } = bindParams(() => ({
  locale: locales.getLocale(),
}));

apps/docs/src/links.ts

パターンは /:locale/… と綴ったままなので、表の型はそのまま効きます。束ねた param は省略でき、渡せば上書きできます。

import { href, navigateTo } from './links';

href('/:locale/products/:id', { id: '42' });
href('/:locale/products/:id', { locale: 'en', id: '42' });
navigateTo('/:locale', { locale: 'en' }, { history: 'replace' });
navigateTo('/:locale/products', undefined, { history: 'replace' });

// @ts-expect-error
navigateTo('/:locale/products', { history: 'replace' });

source は呼び出しのたびに読まれます。リクエストや URL ごとに違う値も、その時点の値になります。どのパッケージが値を供給するかはアプリが決めることで、ルーターが知っているのは param の名前だけです。

すべての param が束ねられたパターンでも、navigateTo のオプションは第 2 引数ではなく第 3 引数です。params もオプションも素のオブジェクトなので、どちらかを決めるのはパターンが param を持つかどうかだけです。params の位置には undefined を渡してから、オプションを渡します。第 2 引数にオプションを書くと型エラーになります。

戻り値の型は BoundLinks<Bound>source が返す値の型は BoundParamsParamValue の読み取り専用レコード)です。

useParamsuseRoute

<Router> の下のコンポーネントは、今描かれているルートの params をコンテキストから読みます。useParams(pattern) はパターン文字列から型の付いた params を返します。値は常に文字列です。

// src/pages/product-page.tsx
import { useParams } from '@k8ordo/router';

export function ProductPage() {
  const { id } = useParams('/products/:id');
  return <h1>Product {id}</h1>;
}

useParams に渡すパターンは「このコンポーネントはこのパターンの下で描かれる」という宣言で、実行時に確かめられます。別のパターンの下で描かれると、形の違う params を黙って返すのではなく throw します。

useParams("/products/:id") rendered under "/products"

useRoute() は型の無い形で、勝ったパターンと params を返します。いくつものルートで使い回すコンポーネントが、どのルートの下にいるかを見分けるときに使います。照合結果が無ければ throw します。

// src/breadcrumb.tsx
import { href, useRoute } from '@k8ordo/router';

export function Breadcrumb() {
  const { pattern, params } = useRoute();
  if (pattern !== '/products/:id') {
    return null;
  }
  return (
    <nav aria-label="Breadcrumb">
      <a href={href('/products')}>Products</a> / {params['id']}
    </nav>
  );
}
useRoute must render inside a matched <Router>

@k8ordo/static@k8ordo/server の下では、ブラウザに表が無いのでどちらも使えません。ページは params を props で受け取ります。 フレームワーク配下

usePathname で現在地を読む

usePathname() はブラウザが今いる pathname を、末尾のスラッシュを落とした形で返します。表ではなくプラットフォームを読むので、<Router> を自分でマウントしたアプリでもフレームワークの下でも同じように動きます。

pathname が変わったときだけ再描画され、search が変わっても再描画されません。search を返さないのは意図したものです。search が変わるたびに再描画されるコンポーネントは @k8ordo/state のキー単位の購読を台無しにするからで、? で分けることが 2 つのパッケージの境界そのものです。

usePathname は新しいページが出たときではなく、URL が変わったときに変わります。intercept では URL が先に確定し、木は読み込みが終わってから届くので、遅いナビゲーションではリンクが先に選択状態になり、前のページがまだ画面に残ります。ブラウザのアドレスバーと同じ順序です。待ちを見せたいなら、上のように navigateTofinished を待ちます。

値は URL の綴りのままで、デコードしません。ASCII 以外の文字はパーセント符号化された形で返ります。

サーバーでの描画とハイドレーションの間は Navigation API を読めないので、描画した側が <PathnameProvider> で pathname を渡します。<Router> と両モードのランタイムが自分でマウントするので、アプリが書くことはありません。

useMatchmatchPath で選択状態を尋ねる

リンクが選択状態かどうかは props ではなく、尋ねる問いです。useMatch(pattern, options?) は、今の pathname がそのパターンに合えば params を、合わなければ null を返します。

// src/products-nav-link.tsx
import { href, useMatch } from '@k8ordo/router';

export function ProductsNavLink() {
  const onIndex = useMatch('/products') !== null;
  const inSection = useMatch('/products/*', { inclusive: true }) !== null;
  return (
    <a
      aria-current={onIndex ? 'page' : inSection ? 'true' : undefined}
      href={href('/products')}
    >
      Products
    </a>
  );
}

パターンは表のもの、または表のパターンに /* を続けたもので、後者は「その下のどこか」を意味します(型 MatchablePattern)。パターン自身のページは「下」に含まれず、/products/*/products/42 に合い、/products には合いません。index でも下でも選択状態にしたい区画のリンクは { inclusive: true }(型 MatchOptions)を渡します。inclusive/* で終わらないパターンには影響しません。

呼び出し結果
matchPath('/products/:id', '/products/42'){ id: '42' }
matchPath('/products/*', '/products/42'){}
matchPath('/products/*', '/products')null
matchPath('/products/*', '/products', { inclusive: true }){}
matchPath('/:locale/ui/*', '/ja/ui/components/button'){ locale: 'ja' }

matchPath(pattern, pathname, options?) は同じ判定を行う純粋関数で、手元にある pathname を調べます。useMatchusePathname の上に作られているので、再描画されるのは pathname が変わったときだけで、表も要りません。このサイトのサイドナビゲーションも useMatch で「/ui/components の下が開いているか」を尋ねています。

matchPath を試す

パターンと pathname を書き換えると、本物の matchPathnormalizePathname の結果がその場で変わります。最初の値は、このページが属する区画のパターン /:locale/router/* と、今いる pathname です。

呼び出し
matchPath('/:locale/router/*', '/ja/router/links')
normalizePathname
/ja/router/links
結果
{"locale":"ja"}

normalizePathname と末尾のスラッシュ

URLPattern は /products/products/ を別の pathname として扱いますが、ルーターは同じものとして扱います。normalizePathname(pathname) はその規則そのもので、末尾のスラッシュをすべて落とし、ルートの / だけは残します。表の照合・matchPathusePathname<PathnameProvider> はどれもこの形に揃えてから比べます。

入力出力
/products//products
/products////products
//
////
//products//products
/caf%C3%A9//caf%C3%A9

落とすのは末尾のスラッシュだけです。途中の連続したスラッシュ、パーセント符号化、search や fragment には触れません。pathname を表と同じ規則で比べるコードで使います。

@k8ordo/state と同じパスの型を使う

@k8ordo/stateRegister にも、このルーターと同じ 1 行を書きます。両方に同じ表を宣言すると、@k8ordo/state のリンクも、このルーターが照合するのと同じ表で検査され、2 つのパッケージが「パス」について同じ答えを持ちます。

// types/k8ordo.d.ts
import type { routes } from '../src/routes';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
  }
}

declare module '@k8ordo/state' {
  interface Register {
    routes: typeof routes;
  }
}

RouteOf<typeof routes> は表のリンク可能な pathname を union にした型です。@k8ordo/state は内部でこれを使っています。ほかに型付きのパスを受け取るものがあれば、同じ型を渡せます。 @k8ordo/state

import type { RouteOf } from '@k8ordo/router';

import type { routes } from './routes';

type AppPath = RouteOf<typeof routes>;