リンクと現在地
ページは表を import しません。リンクもナビゲーションも現在地の問い合わせも、パターンの文字列だけで書きます。このページでは href と navigateTo、共有の 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> を自分でマウントするアプリ、またはどのスキーマもかからないパターン)、値は string・number・bigint・boolean のどれでも渡せます(型 ParamValue)。どれも綴りが 1 つに決まるからです。値は encodeURIComponent で符号化されるので a/b は a%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' });オプションは NavigateToOptions の history だけで、'push'(既定)か 'replace' です。params を持たないパターンでは、オプションが第 2 引数になります。
既定が push なのは、ページの移動は戻るボタンで取り消せるべきだからです。@k8ordo/state の update() は逆に 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/ui の Button の onAction や <form action> の中で待っても、finished はページが画面に出た時点で解決します。無関係な非同期アクションが保留中の間に始まったページの切り替えも、そのアクションを待たずに画面に出ます。イベントハンドラの中で待つこともできます。
別のナビゲーションが追い越すと、finished は abort の理由(名前が AbortError の DOMException)で reject します。追い越されうる場所で待つコードは、上の例のように abort だけを無視し、それ以外のエラーは投げ直します。 ナビゲーション
Register で表と照合する
params の推論は設定なしで働きます。パターンそのものを実際の表と照合するには、アプリで 1 度だけ Register に routes を宣言します。
// types/k8ordo-router.d.ts
import type { routes } from '../src/routes';
declare module '@k8ordo/router' {
interface Register {
routes: typeof routes;
}
}宣言すると、href・navigateTo・useParams・useMatch・matchPath に渡すパターンが表のものに限られます。宣言の前は / で始まる任意の文字列が通ります。
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 の位置を任意の文字列にしたテンプレートリテラル型 |
ParamValue | URL の綴りが 1 つに決まる値。string | number | bigint | boolean |
共有の params を bindParams で束ねる
ロケールやテナントのように、すべてのリンクが繰り返すことになる区間は、関数から供給します。bindParams(source) は、source が返す params を毎回補う href と navigateTo を返します。このサイト自身の src/links.ts がそのまま例です。
// src/links.ts
import { bindParams } from '@k8ordo/router';
import { locales } from './i18n';
export const { href, navigateTo } = bindParams(() => ({
locale: locales.getLocale(),
}));パターンは /: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 が返す値の型は BoundParams(ParamValue の読み取り専用レコード)です。
useParams と useRoute
<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 が先に確定し、木は読み込みが終わってから届くので、遅いナビゲーションではリンクが先に選択状態になり、前のページがまだ画面に残ります。ブラウザのアドレスバーと同じ順序です。待ちを見せたいなら、上のように navigateTo の finished を待ちます。
値は URL の綴りのままで、デコードしません。ASCII 以外の文字はパーセント符号化された形で返ります。
サーバーでの描画とハイドレーションの間は Navigation API を読めないので、描画した側が <PathnameProvider> で pathname を渡します。<Router> と両モードのランタイムが自分でマウントするので、アプリが書くことはありません。
useMatch と matchPath で選択状態を尋ねる
リンクが選択状態かどうかは 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 を調べます。useMatch は usePathname の上に作られているので、再描画されるのは pathname が変わったときだけで、表も要りません。このサイトのサイドナビゲーションも useMatch で「/ui/components の下が開いているか」を尋ねています。
matchPath を試す
パターンと pathname を書き換えると、本物の matchPath と normalizePathname の結果がその場で変わります。最初の値は、このページが属する区画のパターン /:locale/router/* と、今いる pathname です。
- 呼び出し
matchPath('/:locale/router/*', '/ja/router/links')- normalizePathname
/ja/router/links- 結果
{"locale":"ja"}
normalizePathname と末尾のスラッシュ
URLPattern は /products と /products/ を別の pathname として扱いますが、ルーターは同じものとして扱います。normalizePathname(pathname) はその規則そのもので、末尾のスラッシュをすべて落とし、ルートの / だけは残します。表の照合・matchPath・usePathname・<PathnameProvider> はどれもこの形に揃えてから比べます。
| 入力 | 出力 |
|---|---|
/products/ | /products |
/products/// | /products |
/ | / |
/// | / |
//products | //products |
/caf%C3%A9/ | /caf%C3%A9 |
落とすのは末尾のスラッシュだけです。途中の連続したスラッシュ、パーセント符号化、search や fragment には触れません。pathname を表と同じ規則で比べるコードで使います。
@k8ordo/state と同じパスの型を使う
@k8ordo/state の Register にも、このルーターと同じ 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>;