React Routerのv6.4から採り入れらた新しいData API(「Using v6.4 Data APIs」参照)を使った公式サイト「Tutorial」の作例にもとづくチュートリアルの後編です。TypeScriptを用い、モジュールの組み立てやコードは手直ししました。解説も書き改めています。さらに、要所ごとにStackBlitzのコードサンプルを掲げました。各モジュールのコードが開けて見られ、動作を確認しつつ、書き替えて試すこともできるでしょう。
コンテキストに応じたエラー
遷移の文脈に応じたエラー画面は、ユーザーに役立つことが少なくありません。たとえば、モジュールsrc/routes/destroy.tsx
のaction
で、あえてエラーを投げてみましょう。記録の編集画面で[Delete]ボタンを押して[OK]をクリックすると、エラーが示されます(図001)。
params: { contactId },
export const action: ActionFunction<Params> = async ({
}) => {
throw new Error('oh dang!');
};
図001■編集画面で[Delete]を実行するとエラーが示される
この画面は、前回ルートパス(/
)のRoute
オブジェクトにerrorElement
として与えたErrorPage
コンポーネントです。これで、エラーが起きたことはわかります。けれど、ユーザーはもとの画面をロードし直すことくらいしかできません。
コンテキストに応じたエラー画面を、contacts/:contactId/destroy
パスに加えましょう。子Route
オブジェクトでも、定めるプロパティはerrorElement
です。編集画面で[Delete]を実行すると、表示されるのは新たなエラー画面に変わりました(図002)。
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
path: 'contacts/:contactId/destroy',
action: destroyAction,
errorElement: <div>Oops! There was an error.</div>,
},
],
},
]);
図002■コンテキストに応じたエラー画面が表示される
もっとも、表示されるのは子ルート(contacts/:contactId/destroy
)のRoute
オブジェクトにerrorElement
として直書きした要素(<div>
)です。実際には、ユーザーに役立つ機能を備えたコンポーネントに差し替えなければなりません。けれど、今回その実装については省きます。
理解いただきたいのは、子ルートのエラーは遡って、もっとも近い親のerrorElement
が描画されるということです。そして、子ルート自体にもerrorElement
は定められ、コンテキストに応じたエラー画面として表示できます。
インデックスルート
アプリケーションを立ち上げて開いたルートパス(/
)は、サイドバーの右側が何もない空白です。ルートがchildren
をもつとき、はじめは親のパスで表示されます。すると、<Outlet>
には何も描画されません。パスに合致する子ルートがないからです。
インデックスルートを用いれば、親のデフォルト子ルートとして描くことができます。まず、インデックスルートのモジュール(src/routes/index.tsx
)をつくりましょう。
import type { FC } from 'react';
export const Index: FC = () => {
return (
<p id="zero-state">
This is a demo for React Router.
<br />
Check out{' '}
<a href="https://reactrouter.com">the docs at reactrouter.com</a>.
</p>
);
};
つぎに、src/App.tsx
モジュールのrouter
です。親ルート(/
)のRoute
オブジェクトにchildren
として、インデックスルートのコンポーネント(Index
)を加えましょう。このとき、子Route
オブジェクトのindex
にtrue
を与えるのが、親ルートパス(/
)のデフォルト子ルートとする定めです。他の子ルートは親とはパスが異なりますので、<Outlet>
には描画されません(逆に、他の子ルートのパスに遷移すると、親のパスのインデックスルートは表示されなくなるということです)。
import { Index } from './routes/index';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{ index: true, element: <Index /> },
],
},
]);
アプリケーションのルートのパス(/
)を開くと、サイドバーの右側に子のインデックスルートが表紙されるようになりました(図003)。
図003■ルートのパス(/
)でサイドバーの右側に子のインデックスルートが表示される
取り消しボタン
編集画面の[Cancel]ボタンがまだ動いていませんでした。機能はブラウザの[戻る]ボタンと同じです。何もせずに、もとの画面に戻します。ボタンのonClick
ハンドラに用いるのは、React RouterのuseNavigate
フックが返す関数(navigate
)です。引数に-1
を与えれば、履歴をひとつ戻ります。
// import { Form, redirect, useLoaderData } from 'react-router-dom';
import { Form, redirect, useLoaderData, useNavigate } from 'react-router-dom';
export const EditContact: FC = () => {
const navigate = useNavigate();
return (
<Form method="post" id="contact-form">
<p>
<button
type="button"
onClick={() => {
navigate(-1);
}}
>
Cancel
</button>
</p>
</Form>
);
};
さて、ボタンのonClick
ハンドラから、フォームが送られるのを防ぐpreventDefault()
メソッドは呼び出しませんでした。これは、<button>
要素のtype
属性をbutton
としたからです。<button type="button">
には既定の動作がないので、フォームは送信されません。
サンプル001■React + TypeScript: React Router Tutorial 05
URLSearchParams
とGET送信
これまで扱ってきたインタラクティブなユーザーインタフェースは、リンクとフォームのふたつでした。
- リンク: URLを変更する。
- フォーム: データを
action
にPOST送信する。
検索フィールドの機能は、いわばふたつの組み合わせです。
- フォームでURLを変更するだけ。
- データは変えない。
検索フォームのモジュールsrc/components/search-form.tsx
は、今のところ標準HTMLの<form>
要素を用いています。React Routerの<Form>
コンポーネントではありません。そして、<input type="search">
に与えられている属性はname="q"
です。
このとき、ブラウザがデフォルトでどう動くか確かめましょう。サイドバーの記録リストから、名前をひとつ検索フィールドに入力して[enter]キーを押してみます。URLに加わるのは、?q=
に続くURLSearchParams
のクエリです。
https://stackblitz-starters-ycxk9t.stackblitz.io/?q=Bill
export const SearchForm: FC = () => {
return (
<div>
<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div id="search-spinner" aria-hidden hidden={true} />
<div className="sr-only" aria-live="polite"></div>
</form>
</div>
);
};
ブラウザはフォームを<input>
要素のname
属性にもとづいてシリアライズします。前掲コードでname
属性の値はq
でした。そのため、URLには?q=
というクエリが添えられたのです。
この<form>
要素には、前に扱ったコードと違ってmethod
属性(method="post"
)が定められていません。すると、デフォルトのmethod
属性値はget
です。つまり、つぎのドキュメントに向けてブラウザがフォームデータを加えるのは、POSTリクエストの本体ではありません。GETリクエストのURLSearchParams
です。
クライアントサイドルーティングのGET送信
クライアントサイドルーティングでフォームを送信し、リストはloader
によりフィルタリングしましょう。モジュールsrc/components/search-form.tsx
の<form>
要素は<Form>
コンポーネントに差し替えてください。
export const SearchForm: FC = () => {
return (
<div>
{/* <form id="search-form" role="search"> */}
<Form id="search-form" role="search">
{/* </form> */}
</Form>
</div>
);
};
loader
(src/routes/root.tsx
)はsearchParams
プロパティでリストをフィルタリングします。クエリ引数の値を取り出すのが、searchParams.get
です。
// export const loader: LoaderFunction = async () => {
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get('q');
// const contacts = await getContacts(undefined);
const contacts = await getContacts(q);
};
ここで用いたメソッドは、POSTでなくGETでした。したがって、React Routerはaction
は呼び出しません。フォームのGET送信はリンクをクリックしたのと同じで、ただURLが変わるだけです。そのため、フィルタリングのコードは、ルート(src/routes/root.tsx
)のaction
でなく、loader
に書きました。
GET送信は、通常のページナビゲーションと変わりません。[戻る]ボタンで前のURLに遷移することもできるのです。
URLとフォームの状態を同期する
今のコードでは、URLとフォームの状態の同期に問題がふたつあることに気づくでしょう。
- 検索後に[戻る]ボタンを押したとき:
- フォームの検索フィールドには入力した値が残っている。
- サイドバーの記録リストはフィルタリングされていない。
- 検索したあとにページを読み込み直したとき:
- フォームの検索フィールドは値が消える。
- サイドバーの記録リストはフィルタリングされたまま。
先に、上記2の問題です。モジュールsrc/routes/root.tsx
のloader
からq
の値をオブジェクトに加えて返します。
export const loader: LoaderFunction = async ({ request }) => {
// return { contacts };
return { contacts, q };
};
コンポーネントSearchForm
(src/components/search-form.tsx
)は、useLoaderData
でq
の値を得て、<input>
要素のdefaultValue
に与えればよいのです。これで、検索語にページをリロードしても、検索フィールドのクエリ値は消えません。
// import { Form } from 'react-router-dom';
import { Form, useLoaderData } from 'react-router-dom';
export const SearchForm: FC = () => {
const { q } = useLoaderData();
return (
<div>
<Form id="search-form" role="search">
<input
defaultValue={q}
/>
</Form>
</div>
);
};
つぎに、前述1の問題については、[戻る]ボタンをクリックしたら、検索フィールド(<input type="search">
)の値は更新すべきです。SearchForm
(src/components/search-form.tsx
)に加えたuseEffect
からら、DOMのフォームの状態をReactで直接書き替えます。検索フィールドの値は、URLSearchParams
と同期するようになりました。
import { useEffect } from 'react';
export const SearchForm: FC = () => {
useEffect(() => {
(document.getElementById('q') as HTMLInputElement).value = q;
}, [q]);
};
ここで、SearchForm
はコントロールされたコンポーネントではなく、Reactの状態をもたないことが気になるかもしれません(「コンポーネントがコントロールされているかいないか」参照)。コントロールされたコンポーネントとして扱うことはもちろんできます。けれど、同じふるまいをさせるためのコードは、より煩雑になるでしょう。URLをコントロールしたいわけではありません。ユーザーが押した[戻る]/[進む]ボタンに対応させるだけです。
コントロールされたコンポーネントを使う場合、検索フィールドの同期処理は3箇所加えなければなりません。前掲コードなら1箇所で済むのです。
onChange
ハンドラでフォーム送信する
ここで、検索フィールドの仕様が変わりました。[enter]キーでフォーム送信するのでなく、キー入力のたびにフィルタリングをかけたいということです。フックのuseNavigate
は、すでに使いました。今回用いるのは、useSubmit
です。戻り値の関数(submit
)を実行して、フォームが送信できます。この関数をSearchForm
コンポーネント(src/components/search-form.tsx
)のonChange
ハンドラから呼び出せばよいでしょう。
// import { Form, useLoaderData } from 'react-router-dom';
import { Form, useLoaderData, useSubmit } from 'react-router-dom';
export const SearchForm: FC = () => {
const submit = useSubmit();
return (
<div>
<Form id="search-form" role="search">
<input
onChange={({ currentTarget: { form } }) => {
submit(form);
}}
/>
</Form>
</div>
);
};
これで、検索フィールドに入力するたびに、フォームは送信されます(サンプル002)。submit
関数の引数はフォーム(form
)です。onChange
ハンドラに加えられたcurrentTarget
の要素(<input>
)から、親ノードのform
を取り出しました。関数は渡されたフォームをシリアライズして送信します。
サンプル002■React + TypeScript: React Router Tutorial 06
検索にスピナーを加える
実際のアプリケーションの検索では、大きなデータベースから記録を探すことになるかもしれません。すると、データを一度に送れなかったり、フィルタリングに時間がかかったりもするでしょう。このチュートリアル作例では、あえてネットワークの遅れを擬似的に加えています。
ローディングの進行状況が示されないと、検索は遅く感じがちです。データベースを高速化できたとしても、ユーザー側のネットワーク遅延は生じます。開発側からはコントロールできません。ユーザー体験改善のため、インタフェースに素速い検索のフィードバックを加えましょう。ふたたび用いるのは、useNavigation
です。
SearchForm
コンポーネント(src/components/search-form.tsx
)に検索スピナーの要素(<div id="search-spinner">
)はすでに加えてあり、ただし非表示(hidden={true}
)にしてありました。
import {
useNavigation,
} from 'react-router-dom';
export const SearchForm: FC = () => {
const { location } = useNavigation();
const searching = location && new URLSearchParams(location.search).has('q');
return (
<div>
<Form id="search-form" role="search">
<input
id="q"
className={searching ? 'loading' : ''}
/>
{/* <div id="search-spinner" aria-hidden hidden={true} /> */}
<div id="search-spinner" aria-hidden hidden={!searching} />
</Form>
</div>
);
};
useNavigation
の戻り値から取り出したのは、アプリケーションが新しいURLに遷移して、データを読み込むときにつくられるlocation
です。保留されているナビゲーションがなくなれば消えます。location.search
で調べられるのは、クエリ文字列(?
に続くkey=value
の組み)です(「URL: search プロパティ」参照)。
検索データを読み込む間、入力フィールドの左端に検索スピナーが表れるようになりました(図004)。
図004■検索中入力フィールド左端に検索スピナーが表れる
履歴スタックを管理する
フォームは検索フィールドに入力するたびに送信されるようになりました。けれど、履歴スタックに積み上がります。"Bill"とタイプし、ひと文字ずつ消し去ると、新たに7つの履歴がスタックに残ってしまうのです(図005)。ユーザーにとって好ましいこととはいえません。
図005■入力ひと文字ごとに履歴スタックが加わる
これを避けるには、履歴スタックにつぎのページを追加するのでなく、現行の入力と置き替えることです。その場合、submit
の第2引数オブジェクトにreplace
オプションを与えてください。
export const SearchForm: FC = () => {
return (
<div>
<Form id="search-form" role="search">
<input
id="q"
onChange={({ currentTarget: { form } }) => {
const isFirstSearch = q == null;
// submit(form);
submit(form, {
replace: !isFirstSearch,
});
}}
/>
</div>
);
};
置き替えたいのは検索結果です。検索をはじめた(クエリのない)ページは履歴に残さなければなりません。そのため、クエリの有無(q == null
)を調べたうえで、置き替えるかどうか(isFirstSearch
)決めました。
検索フィールドでタイプしても、もはや履歴スタックの入力は増えません。[戻る]ボタンひとつで、フィルタリングされていない検索をはじめる画面に戻れるでしょう。
遷移なしにデータを変更する
これまで、データを変えようとするときは、フォームで遷移して、履歴スタックには新たな入力がつくられて加わりました。この処理の流れは一般的です。けれど、遷移することなくデータを変更したい場合も少なくありません。
このようなときに用いるのがuseFetcher
フックです。loader
やaction
と、ナビゲーションはなしにやりとりできます。各記録につけるお気に入り(★)ボタンは適した例でしょう。記録の作成・削除もページ遷移も要りません。表示されているページのデータを変更したいだけです。
Favorite
コンポーネント(src/routes/contact.tsx
)の<Form>
を<fetcher.Form>
に置き替えてください。コンポーネントのボタン(<button>
)には、name="favorite"
が与えられています。このキーで送られるformData
の値(value
)は'false' | 'true'
、<fetcher.Form>
からの送信はmethod="post"
です。
// import { Form, useLoaderData } from 'react-router-dom';
import { Form, useFetcher, useLoaderData } from 'react-router-dom';
const Favorite: FC<{ contact: ContactType }> = ({ contact }) => {
const fetcher = useFetcher();
let favorite = contact.favorite;
return (
// <Form method="post">
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? 'false' : 'true'}
aria-label={favorite ? 'Remove from favorites' : 'Add to favorites'}
>
{favorite ? '★' : '☆'}
</button>
{/* </Form> */}
</fetcher.Form>
);
};
<fetcher.Form>
にaction
プロパティの定めはないので、フォームが描画されるルート(src/routes/contact.tsx
)のaction
関数にPOST送信されます。action
はrequest
から取り出したformData
と、データモジュールの関数updateContact
によりデータを更新するだけです。
// import { getContact } from '../contacts';
import { getContact, updateContact } from '../contacts';
export const action = async ({ request, params: { contactId } }) => {
const formData = await request.formData();
return updateContact(contactId, {
favorite: formData.get('favorite') === 'true',
});
};
あとは、モジュールsrc/App.tsx
のrouter
でchildren
に、子Route
オブジェクトのaction
として新たに定めた関数を加えてください。
// import { Contact, loader as contactLoader } from './routes/contact';
import {
Contact,
loader as contactLoader,
action as contactAction,
} from './routes/contact';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
path: 'contacts/:contactId',
element: <Contact />,
loader: contactLoader,
action: contactAction,
},
],
},
]);
各記録で名前の右にある☆をクリックすると、お気に入り表示となり、サイドバーのリストにも印が加わります(図006)。
図006■記録の名前右側のボタンでお気に入り表示が切り替わる
<fetcher.Form method="post">
と<Form method="post">
の基本的な機能は同じです。POST送信でaction
を呼び出し、すべてのデータは自動的に再検証されます。エラーの拾い方も変わりありません。ひとつ異なる重要な点は、<fetcher.Form>
がナビゲーションしないことです。URLは変わらず、履歴スタックにも影響を及ぼしません。
楽観的な(Optimistic)UI更新
お気に入りボタンの反応が遅いと感じたかもしれません。ここでも作例には、ネットワークの遅延を擬似的に仕込んでありました。遅れが生じたのは、Favorite
コンポーネント(src/routes/contact.tsx
)が更新データ(contact
)のロードを待ったからです。読み込み中のフィードバックを与える手もあるでしょう。その場合に使えるのは、navigation.state
と同様の機能を果たすfetcher.state
です。
けれど、今回は「楽観的な(Optimistic)UI更新」の手法を用います。fetcher
がaction
に送るのは更新するフォームデータ(formData
)です。したがって、fetcher.formData
から新しい値は得られます。状態の更新はネットワーク通信の終わりを待つまでもありません。更新できなかったとき、ユーザーインタフェースがデータをもとに戻せばよいのです。
const Favorite: FC<{ contact: ContactType }> = ({ contact }) => {
let favorite = contact.favorite;
if (fetcher.formData) {
favorite = fetcher.formData.get('favorite') === 'true';
}
};
今度は、お気に入りボタンをクリックすると、すぐに新たな状態に切り替わります。実際のデータがレンダーされるまで、わざわざ待たないからです。上記コードは、fetcher.formData
が送信されたら、その値をただちに用います。そして、action
が完了すると、fetcher.formData
は消滅するのです。実際に書き替えられたデータは、そこで改めて使います。楽観的なUIのコードに不具合があって更新できなかったとしても、もとの正しい状態に戻り、そのままの値は残りません。
データが見つからないとき(Not Found)
さて、記録が存在しない動的セグメント、たとえば/contact/not-exist
でルートを遷移するるとどうなるでしょう。開くのはrouter
(src/App.tsx
)のルート(/
)に加えたerrorElement
のエラー画面です(図007)。
図007■記録が存在しない動的セグメントでルートを遷移する
ただ、null
のプロパティavatar
が読み込めないと告げられてもよくわかりません。実際には、src/routes/contact.tsx
のloader
に渡されたparams
のcontactId
が記録データの中に見つからなかったということです。そのため、Contact
コンポーネントから呼び出されたuseLoaderData
は有効なcontact
を返せず(null
)、avatar
プロパティも取り出せませんでした。
export const loader: LoaderFunction<Params> = async ({
params: { contactId },
}) => {
const contact = await getContact(contactId);
return { contact };
};
export const Contact: FC = () => {
const { contact } = useLoaderData() as { contact: ContactType };
return (
<div id="contact">
<div>
<img key={contact.avatar} src={contact.avatar || undefined} />
</div>
</div>
);
};
要は、ページが見つからなかった(Not Found)ということです。コードから明示的にエラーを投げれば、内容がわかりやすく示せます。
export const loader: LoaderFunction<Params> = async ({
params: { contactId },
}) => {
if (!contact) {
throw new Response('', {
status: 404,
statusText: 'Not Found',
});
}
};
図008■404 Not Foundエラー画面が表示された
パスなしのルート
前項のエラーページにはサイドバーがありません。サイドバーの右に表示したいときは、ルート(/
)のRoute
オブジェクトにchildren
配列として加えて、Root
コンポーネントの<Outlet />
に表示します。このとき、つぎのように入れ子になったRoute
オブジェクトには、errorElement
とchildren
だけでpath
がありません。これが今回のルート構成を実現するパスなしのルートという手法です。
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
children: [
{
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Index /> },
],
},
],
},
]);
サイドバーの右にNot Foundエラーが表示されるので、エラーは気にせず別の記録を選ぶこともできるようになりました(図009)。まとめたStackBlitzのコードは、以下のサンプル003のとおりです。
図009■サイドバーの右にNot Foundエラーが表示される
サンプル003■React + TypeScript: React Router Tutorial 07
JSXでルートを定める
ルート(router
)は、オブジェクト以外に、JSXの構文でも定められます。そのJSXを引数として受け取るのがcreateRoutesFromElements
です。戻り値は、createBrowserRouter
の引数に渡します。ですから、機能はどちらの構文でも変わりません。お好みでお選びください。
JSXではルートは<Route>
コンポーネントで、プロパティはその属性として与えます。ただし、children
プロパティはないので、<Route>
コンポーネントを入れ子にしてください。
前掲サンプル003のルートをJSXに書き替える場合、修正するモジュールはsrc/App.tsx
です。JSXに書き改めたコードは、サンプル004に掲げました。
// import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import {
createRoutesFromElements,
createBrowserRouter,
Route,
RouterProvider,
} from 'react-router-dom';
/* const router = createBrowserRouter([
{
},
]); */
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path="/"
element={<Root />}
loader={rootLoader}
action={rootAction}
errorElement={<ErrorPage />}
>
<Route errorElement={<ErrorPage />}>
<Route index element={<Index />} />
<Route
path="contacts/:contactId"
element={<Contact />}
loader={contactLoader}
action={contactAction}
/>
<Route
path="contacts/:contactId/edit"
element={<EditContact />}
loader={contactLoader}
action={editAction}
/>
<Route path="contacts/:contactId/destroy" action={destroyAction} />
</Route>
</Route>
)
);
サンプル004■React + TypeScript: React Router Tutorial 08