ルート表
ルート表は、アプリが答える pathname を 1 か所に並べたものです。このページでは表の文法、照合の順序、定義した時点で拒まれる書き方、表に書くエラー境界、そして表から導かれる型を扱います。
leaf と branch
表の値は 2 種類です。leaf は描画するコンポーネントそのもので、branch は { layout?, error?, children } です。branch の children は同じ形の表で、キーは親のパターンに続けて読みます。
// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { lazy } from 'react';
import { Home } from './pages/home';
import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { ProductsLayout } from './products-layout';
import { RootLayout } from './root-layout';
const Settings = lazy(() => import('./pages/settings'));
export const routes = defineRoutes({
'/': {
layout: RootLayout,
children: {
'/': Home,
'/products': {
layout: ProductsLayout,
children: {
'/': ProductList,
'/:id': ProductPage,
},
},
'/settings': Settings,
},
},
});子のキー / は branch 自身の index ページです。ルートに置いた / の branch は URL に何も足さないので、全ページを包むレイアウトになります。
照合の結果は、外側のレイアウトから順に並べ、最後に leaf を置いたスタックです。レイアウトは次の要素を <Outlet /> で描きます。/products/42 なら RootLayout → ProductsLayout → ProductPage の順に入れ子になります。
// src/root-layout.tsx
import { Outlet } from '@k8ordo/router';
import { Suspense } from 'react';
export function RootLayout() {
return (
<>
<header>Shop</header>
<Suspense fallback={<p>Loading…</p>}>
<Outlet />
</Suspense>
</>
);
}// src/products-layout.tsx
import { Outlet } from '@k8ordo/router';
export function ProductsLayout() {
return (
<section>
<h1>Products</h1>
<Outlet />
</section>
);
}<Router> は leaf にもレイアウトにも props を渡しません。params は useParams で読みます。表が受け付けるコンポーネントの型 RouteComponent は ComponentType<never> なので、フレームワークが props を渡すコンポーネントも同じ表に入ります。
React.lazy の戻り値も leaf として置けます。branch は children キーの有無で見分けるので、関数ではない lazy コンポーネントを branch と取り違えません。chunk が届くまでの間に fallback を出す場所として、上のレイアウトに <Suspense> を置きます。fallback が出るのは、最初の描画と、その <Suspense> を新しくマウントするナビゲーションのときです。すでに画面にある <Suspense> の下でページが切り替わるときは、背景での描画が前のページを残します。
パターンの文法
パターンは URLPattern の pathname として照合します。表で使う記法は、固定の区間、:name、末尾の /*、そして URL に現れないグループ /(name) です。
| パターン | pathname | params | 備考 |
|---|---|---|---|
/products | /products/ | {} | 末尾のスラッシュは同じ pathname |
/products/:id | /products/42 | { id: '42' } | :name は 1 区間を捕まえる |
/products/:id | /products/a%2Fb | { id: 'a/b' } | 値はデコードされる |
/products/:id | /products/a/b | null (一致しない) | / をまたがない |
/products/:id | /products/ | null (一致しない) | 空の区間には合わない |
/:locale/* | /ja/no/such/page | { locale: 'ja' } | ワイルドカードの中身は params に入らない |
/:locale/* | /ja | null (一致しない) | /* より前の部分そのものには合わない |
'/(docs)' › '/guide' | /guide | {} | グループは URL に区間を足さない |
:param
:name は空でない 1 区間を捕まえ、decodeURIComponent でデコードした文字列を返します。デコードできない綴りは書かれたまま残します。型はパターン文字列から推論され、/:locale/products/:id の params は { locale: string; id: string } です。
ワイルドカード /*
/* はそれより前の何にも合わなかった pathname を受けます。1 区間ではなく、下に続く任意の区間に合います。照合には使えますがリンク先にはならないので、href と navigateTo は実行時に TypeError で拒みます。Register を宣言していれば、型の段階でも拒みます。
ルートの /* は / 自身にも合います。表の最後に置くのはこのためでもあります。
グループ /(name)
グループは表を構造化します。自分のレイアウトと部分木を持ちますが、URL に区間を足しません。1 つのオブジェクトに / は 1 度しか書けないので、同じ深さの 2 つの区画に別々のレイアウトを持たせるにはグループが要ります。
// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { DocsLayout } from './docs-layout';
import { MarketingLayout } from './marketing-layout';
import { Guide } from './pages/guide';
import { Home } from './pages/home';
import { Pricing } from './pages/pricing';
export const routes = defineRoutes({
'/(marketing)': {
layout: MarketingLayout,
children: {
'/': Home,
'/pricing': Pricing,
},
},
'/(docs)': {
layout: DocsLayout,
children: {
'/guide': Guide,
},
},
});上の表で /pricing は MarketingLayout の中、/guide は DocsLayout の中に描かれ、URL には marketing も docs も現れません。グループは branch の中にも置けます。
書いた順が規則
照合は表を上から順にたどり、最初に合ったパターンを採ります。優先順位は書いた順そのもので、特異度のランキングはありません。表はコードと同じように上から読めば答えが分かります。
// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { NewProduct } from './pages/new-product';
import { ProductPage } from './pages/product-page';
export const routes = defineRoutes({
'/products/:id': ProductPage,
'/products/new': NewProduct,
});この順では /products/new が ProductPage に合い、id が new になります。
// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { NewProduct } from './pages/new-product';
import { ProductPage } from './pages/product-page';
export const routes = defineRoutes({
'/products/new': NewProduct,
'/products/:id': ProductPage,
});固定の区間を先に書けば、/products/new は NewProduct に、それ以外の /products/… は ProductPage に届きます。
表を直接引く
defineRoutes が返す Routes の操作は match(pathname, accept?) の 1 つです。勝ったパターン・params・スタックを Match として返し、何にも合わなければ null を返します。ブラウザを必要としないので、テストで表の形と優先順位を直接確かめられます。
// src/routes.test.ts
import type { Match } from '@k8ordo/router';
import { expect, it } from 'vitest';
import { routes } from './routes';
it('sends /products/new to its own page, not to :id', () => {
expect(routes.match('/products/new')?.pattern).toBe('/products/new');
expect(routes.match('/products/42')?.params).toStrictEqual({ id: '42' });
expect(routes.match('/nowhere')).toBeNull();
});
it('walks on when the caller declines a fit', () => {
const onlyNumericIds = (found: Match) =>
found.pattern !== '/products/:id' ||
/^\d+$/u.test(found.params['id'] ?? '');
expect(routes.match('/products/7', onlyNumericIds)?.pattern).toBe(
'/products/:id',
);
expect(routes.match('/products/shoes', onlyNumericIds)).toBeNull();
});accept は合ったものを呼び出し側が断るための関数で、false を返すと照合はそのパターンが合わなかったものとして次へ進みます。フレームワークはこれで、スキーマが拒んだ params を「そのパターンが答えない pathname」として扱っています。
定義した時点で拒まれるもの
表はモジュールが読み込まれたときに検査されます。誰かが最初にそのページへ移動したときではありません。どのエラーも TypeError です。
| 書いたもの | エラー |
|---|---|
/ で始まらないキー{ 'products': Products } | route pattern "products" must start with "/" |
グループそのものではない括弧{ '/(admin)/new': NewItem } | route group "/(admin)/new" must be "/(name)" and nothing else — a regular expression is not part of the grammar |
children を持たないグループ{ '/(oops)': Home } | route group "/(oops)" must have children |
同じ完全パターンの 2 度目(入れ子の位置が違っても){ '/x': A, '/': { children: { '/x': B } } } | route pattern "/x" is declared twice |
URLPattern が解釈できないパターン{ '/a{b': Home } | URLPattern 自身の TypeError |
括弧を拒むのは、URLPattern が (…) を正規表現のグループとして読むからです。/(admin)/new を通すと、名前の無い param を捕まえながら /admin/new に黙って合ってしまいます。文法での括弧の意味はグループの 1 つだけです。グループに children を求めるのは、区間を足さない leaf が親の index の 2 度目の宣言になってしまうからです。
エラー境界
branch には layout と並べて error を書けます。その下のどこかが描画中に throw すると、レイアウトの穴に error のコンポーネントが代わりに描かれ、レイアウトという枠はそのまま残ります。
// src/products-error.tsx
import type { ErrorProps } from '@k8ordo/router';
import { href } from '@k8ordo/router';
export function ProductsError({ error, reset }: ErrorProps) {
return (
<div role="alert">
<p>{error instanceof Error ? error.message : 'Something went wrong'}</p>
<button onClick={reset} type="button">
Try again
</button>
<a href={href('/products')}>Back to the list</a>
</div>
);
}// src/routes.ts
import { defineRoutes } from '@k8ordo/router';
import { ProductList } from './pages/product-list';
import { ProductPage } from './pages/product-page';
import { ProductsError } from './products-error';
import { ProductsLayout } from './products-layout';
export const routes = defineRoutes({
'/products': {
layout: ProductsLayout,
error: ProductsError,
children: {
'/': ProductList,
'/:id': ProductPage,
},
},
});error のコンポーネントは ErrorProps({ error, reset })を受け取ります。型は ErrorComponent です。error は throw された値そのもので unknown 型です。reset() はその場で部分木をもう一度描画し、再び throw すればまた error が出ます。
失敗したページを離れると、失敗は消えます。境界は NavigationGeneration(新しい木が画面に適用されるたびに変わる番号)をキーにしているので、別のページへ移ると作り直されます。pathname をキーにしないのは、URL が木より先に確定するからです。search だけが変わる状態の更新では木が変わらないので、失敗もそのまま残ります。
境界はレイアウトの内側にあるので、レイアウト自身の throw は同じ branch の error では受け止められず、さらに外側の branch の error に届きます。どこにも境界が無ければ、エラーは <Router> の外へ出ます。
境界は下の部分木を fallback が null の <Suspense> でも包みます(サーバーでの描画で throw した部分木をブラウザに任せるため)。そのため error を持つ branch の下にある React.lazy のページは、最初の描画やその branch に入るナビゲーションで chunk を待つ間、上のレイアウトの <Suspense> ではなくこの境界の中で何も描きません。fallback を見せたいなら、<Suspense> をその branch より下のレイアウトに置くか、lazy コンポーネントを直接包みます。
@k8ordo/static と @k8ordo/server では、error.tsx が生成された表のこの error になります。 フレームワーク配下
表から導かれる型
表の型はパターン文字列からの推論だけで決まり、コード生成を使いません。routes を Get Started で作った表(/・/products・/products/:id・/*)とすると、3 つの型は 2 つ目のコードのとおりに解決されます。
import type {
NavigablePatternOf,
PatternOf,
RouteOf,
} from '@k8ordo/router';
import type { routes } from './routes';
type Pattern = PatternOf<typeof routes.record>;
type Linkable = NavigablePatternOf<typeof routes.record>;
type Path = RouteOf<typeof routes>;type Pattern = '/' | '/products' | '/products/:id' | '/*';
type Linkable = '/' | '/products' | '/products/:id';
type Path = '/' | '/products' | `/products/${string}`;| 型 | 意味 |
|---|---|
PatternOf<R> | 表のすべての leaf パターン(書いたとおりの綴り) |
NavigablePatternOf<R> | リンク先にできるパターン。ワイルドカードを除いたもの |
RouteOf<typeof routes> | 表の pathname 空間。リンク可能なパターンの :param を任意の文字列にした union |
Routes<R> | defineRoutes の戻り値。kind・record(渡した表)・match を持つ |
RoutesRecord | 表そのものの型。キーは / で始まる文字列 |
RouteNode | 表の値。RouteComponent か branch |
RouteComponent | leaf とレイアウトの型。ComponentType<never> |
Match | match の戻り値。pattern・params・stack(外側から順、leaf が最後) |
RouteOf は @k8ordo/state の Register が型付きのパスに使う型です。 リンクと現在地