React Routerは、Reactに使われるもっとも人気の高いルーティングのライブラリです。シングルページアプリケーション(SPA)では、ひとつのページに表示されるコンポーネントを切り替えて複数のページが表現されます。それらのページにそれぞれURLを与えて遷移するのがルーティングの技術です。
そして、React Routerは「クライアントサイドルーティング」を実現します。通常のWebサーバーでは、ページ遷移のたびにドキュメント全体をリクエストして、コンテンツすべてがロードし直されなければなりません。クライアントサイドルーティングは、すでに読み込まれたJavaScriptで、必要な箇所はただちに更新します。そのうえで、新たに使うデータのみサーバーにリクエストするのです。ページを丸ごとリロードする場合と比べて、負荷がかかりません。
React Routerにはv6.4から新たなData APIが採り入れられました(「Using v6.4 Data APIs」参照)。これからは、新APIを使うことが推奨です。このチュートリアルは、新しい構文を使った公式サイトの「Tutorial」の作例にもとづきます。ただし、TypeScriptを用い、モジュールの組み立てやコードは手直ししました。解説も書き改めています。
さらに、公式「Tutorial」の説明やコードはわかりやすいものの、それぞれのモジュールの記述全体や実際の動きを確かめたいとき、自分で頭から書いてみるしかありません。そこで、このチュートリアルでは、要所ごとにStackBlitzのコードサンプルを掲げました。各モジュールのコードが開けて見られ、動作を確認しつつ、書き替えて試すこともできるでしょう。公式「Tutorial」の記事は少し長いので、本チュートリアル解説はふたつに分けることにしました。本稿はその前編[01]です。
インストールと設定
まず、プロジェクトのひな形をつくりましょう。Create React Appでも結構ですし、React Router公式「Tutorial」ではVite
を用いています。
React Routerのローカルへのインストールそのものは、つぎのとおりです。
npm install react-router-dom
けれども、チュートリアルでは、ほかにも3つのライブラリを使います。
- localForage: Webアプリケーションのデータを、簡単に保存したり、取り出せる。
- match-sorter: 配列要素を絞り込んで並べ替える。
- sort-by-typescript: 配列要素をキーで並べ替える。
そのため、つぎのようにセットアップしてください。
npm install react-router-dom localforage match-sorter sort-by-typescript
このチュートリアルでは、StackBlitzのテンプレート[React TypeScript]を使いました(他のツールでひな形プロジェクトをつくった場合は、ファイル名などは読み替えなければなりません)。react-router-dom
と前掲3つのライブラリは、[Dependencies]に加えてください(後掲サンプル001参照)。
アプリケーションのはじめのモジュール構成はつぎのとおりです。src/styles.css
の定めは書き替え、src/contacts.ts
とsrc/types.ts
のふたつのファイルを加えました。それぞれ後掲サンプル001のStackBlitz作例からコードをコピーしてください。
src
├── App.tsx
├── contacts.ts
├── index.tsx
├── styles.css
└── types.ts
src/contacts.ts
は、WebサーバーとAPIのやり取りをするデータモジュールです。チュートリアルの主題であるReact Routerには関わらないので、解説中ではほぼ触れません(先述のセットアップで加えた3つのライブラリを用いるのはこのモジュールです)。
ルートのルーティングを加える
ルートのルーティングに加えるモジュールsrc/routes/root.tsx
は、つぎのように定めましょう。SearchForm
コンポーネントはこのあとでつくります。
import type { FC } from 'react';
import { SearchForm } from '../components/search-form';
export const Root: FC = () => {
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<SearchForm />
</div>
<div id="detail"></div>
</>
);
};
React Router 6.4のData APIでブラウザルーターをつくるのがcreateBrowserRouter
です。引数には、Route
オブジェクトの配列(routes
)を渡してください。ルーティングのURLがpath
、element
は表示するコンポーネントです。戻り値のルーター(router
)は<RouterProvider>
コンポーネントのrouter
プロパティに与えます。
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Root } from './routes/root';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
},
]);
// export const App: FC<{ name: string }> = ({ name }) => {
export const App: FC = () => {
return (
/* <div>
<h1>Hello {name}!</h1>
<p>Start editing to see some magic happen :)</p>
</div> */
<RouterProvider router={router} />
);
};
root.render(
<StrictMode>
{/* <App name="StackBlitz" /> */}
<App />
</StrictMode>
);
新たに定めるSearchForm
コンポーネントのモジュールsrc/components/search-form.tsx
のコードはつぎのとおりです。
import type { FC } from 'react';
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>
<form method="post">
<button type="submit">New</button>
</form>
</div>
);
};
さらに、ルートにはもうひとつコンポーネント(Contacts
)を加えましょう。つぎのモジュールsrc/components/contacts.tsx
がそのコードです。
import type { FC } from 'react';
export const Contacts: FC = () => {
return (
<nav>
<ul>
<li>
<a href={`/contacts/1`}>Your Name</a>
</li>
<li>
<a href={`/contacts/2`}>Your Friend</a>
</li>
</ul>
</nav>
);
};
ルートのモジュールsrc/routes/root.tsx
に、コンポーネントContacts
を追加してください。これで、ルーティングのルート(/
)画面にサイドバーが表示されました(図001)。StackBlitzに公開したコードが、以下のサンプル001です。
import { Contacts } from "../components/contacts";
export const Root: FC = () => {
return (
<>
<div id="sidebar">
<Contacts />
</div>
</>
);
};
図001■ルーティングのルート画面に表示されたサイドバー
サンプル001■React + TypeScript: React Router Tutorial 01
Not Foundエラーページを加える
ルーティングが正しく動かないとき、ページは見つからないことになります。いわゆる「Not Found (404)」エラーです。React Routerにはデフォルトのエラー画面が備わっています。
たとえば、サイドバーの「Your Name」(/contacts/1
)か「Your Friend」(/contacts/2
)をクリックしてみましょう。遷移先パスのルーティングはまだ定めていません。つくっているアプリケーションのスタイル設定など気にしていませんので、表示されるのは見苦しい「404 Not Found」エラー画面です(図002)。
図002■デフォルトの404 Not Foundエラー画面
React Routerは、ルーティングでつぎのようなときに起こるエラーを拾います。
- レンダリング
- データの読み込み
- データの変更
エラーを確かめるフックはuseRouteError
です。自作のエラーページのモジュールsrc/components/error-page.tsx
をつぎのように定めましょう。ルーティングの本題に関わらないコードの中身は、かなり手を抜きました。
import type { FC } from 'react';
import { useRouteError } from 'react-router-dom';
export const ErrorPage: FC = () => {
const error = useRouteError() as { statusText?: string; message?: string };
console.error(error);
return (
<div id="error-page">
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
);
};
エラー画面のコンポーネントErrorPage
は、モジュールsrc/App.tsx
のrouter
のルート(/
)Route
オブジェクトに、前掲図002のエラーメッセージで示されていたとおりerrorElement
として加えます。これで、Not Foundのエラーは、自作のコンポーネントで表示されるでしょう(図003)。
import { ErrorPage } from './components/error-page';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <ErrorPage />,
},
]);
図003■自作のコンポーネントで表示されたNot Foundエラー
記録情報を表示するルートのインタフェース
アプリケーションに備えるのは、面会者(コンタクト)の情報を記録して表示する機能です。サイドバーの右側、メイン画面に加える情報表示のモジュールsrc/routes/contact.tsx
はつぎのように定めます。記録(contact
)のデータは、今のところ決め打ちです。
import React from 'react';
import type { FC } from 'react';
import { Form } from 'react-router-dom';
import type { ContactType } from "../types";
export const Contact: FC = () => {
const contact: ContactType = {
first: 'Your',
last: 'Name',
avatar: 'https://placekitten.com/g/200/200',
twitter: 'your_handle',
notes: 'Some notes',
favorite: true,
};
return (
<div id="contact">
<div>
<img key={contact.avatar} src={contact.avatar || undefined} />
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{' '}
<Favorite contact={contact} />
</h1>
{contact.twitter && (
<p>
<a target="_blank" href={`https://twitter.com/${contact.twitter}`}>
{contact.twitter}
</a>
</p>
)}
{contact.notes && <p>{contact.notes}</p>}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (!confirm('Please confirm you want to delete this record.')) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
};
const Favorite: FC<{ contact: ContactType }> = ({ contact }) => {
// 変数値はあとで書き替えるためletで宣言
let favorite = contact.favorite;
return (
<Form method="post">
<button
name="favorite"
value={favorite ? 'false' : 'true'}
aria-label={favorite ? 'Remove from favorites' : 'Add to favorites'}
>
{favorite ? '★' : '☆'}
</button>
</Form>
);
};
そうしたら、情報表示のコンポーネント(Contact
)のルートを、src/App.tsx
モジュールのrouter
にRoute
オブジェクトとしてつぎのように加えます。path
に定めたURLの中でコロン(:
)が頭についたセグメント(:contactId
)は「動的セグメント」の記述です。/contacts/1
に遷移すれば、:contactId
は1
と合致してルーティングされ、情報表示画面(Contact
コンポーネント)が開きます(図004)。変数のように動的に変化するわけです。
import { Contact } from './routes/contact';
const router = createBrowserRouter([
{
path: 'contacts/:contactId',
element: <Contact />,
},
]);
図004■動的セグメントに合致してルーティングされた情報表示画面
もっとも、コンポーネントはRoot
がContact
に差し替わっています。そのため、サイドバーは消えてしまいました。サイドバーを残すには、Contact
はRoot
の入れ子にしなければなりません。そのとき、router
に定めたRoute
オブジェクトに用いるのがchildren
プロパティです。入れ子オブジェクトを配列に収めて与えてください。
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <ErrorPage />,
children: [
{
path: 'contacts/:contactId',
element: <Contact />,
},
],
},
/* {
path: 'contacts/:contactId',
element: <Contact />,
}, */
]);
もうひとつ、入れ子ルートをどこにレンダーするのか、親コンポーネント(Root
)のJSXに加えなければなりません。子ルートを示すのは<Outlet>
です。こうして、親ルートのサイドバーは残しながら、子ルートのURLに遷移して、情報表示画面が開けるようになりました(図005)。
import { Outlet } from 'react-router-dom';
export const Root: FC = () => {
return (
<>
<div id="detail">
<Outlet />
</div>
</>
);
};
図005■親のサイドバーは残しながら子ルートに遷移して記録情報を表示する
サイドバーのリンクは<a>
要素で加えました(src/components/contacts.tsx
)。すると、クリックしたとき、遷移するURLのドキュメントをそっくりブラウザにリクエストします。ブラウザの[デベロッパーツール]から開く[ネットワーク]タブで、リクエストの中身を確かめてみてください。
クライアントサイドルーティングを用いれば、アプリケーションはサーバーに別URLのドキュメント丸ごとはリクエストしません。そのとき使うのが、<Link>
コンポーネントです。遷移先URLはto
プロパティに与えます。モジュールsrc/components/contacts.tsx
のJSXで<a>
要素を<Link>
コンポーネントに差し替えましょう(サンプル002)。ユーザーインタフェースがただちにレンダーされ、[デベロッパーツール]の[ネットワーク]でドキュメント全体のリクエストはなくなったことがわかるはずです。
import { Link } from 'react-router-dom';
export const Contacts: FC = () => {
return (
<nav>
<ul>
<li>
{/* <a href={`/contacts/1`}>Your Name</a> */}
<Link to={`/contacts/1`}>Your Name</Link>
</li>
<li>
{/* <a href={`/contacts/2`}>Your Friend</a> */}
<Link to={`/contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
);
};
サンプル002■React + TypeScript: React Router Tutorial 02
データを読み込む
URLセグメントやレイアウト、およびデータは複数のルートの間で互いに連携します。このアプリケーションでは、つぎのふたつのルートです。
URLセグメント | コンポーネント | データ |
---|---|---|
/ |
<Root> |
記録情報のリスト |
contacts/:contactId |
<Contact> |
個別の記録情報 |
ふたつのルートをデータの読み込みで連携するためには、loader
とuseLoaderData
を用いて、3つの手順にしたがいます。
- ルートに
loader
関数を定める。 -
router
のRoot
オブジェクトに関数をloader
プロパティとして加える。 -
useLoaderData
でデータを参照してレンダーする。
ルートにloader
関数を定める
loader
関数はデータを読み込んで使うルートのモジュール(src/routes/root.tsx
)に定めてexport
しましょう。
import type { LoaderFunction } from "react-router-dom";
import { getContacts } from "../contacts";
export const loader: LoaderFunction = async () => {
const contacts = await getContacts(undefined);
return { contacts };
};
router
のRoot
オブジェクトにloader
プロパティを加える
loader
関数はimport
して、router
のRoute
オブジェクトにloader
プロパティとして定めなければなりません。
// import { Root } from './routes/root';
import { Root, loader as rootLoader } from './routes/root';
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
children: [
],
},
]);
useLoaderData
でデータを参照してレンダーする
loader
関数が読み込んだデータは、useLoaderData
フックで取り出せます。なお、loader
関数が定められた子コンポーネント(src/components/contacts.tsx
)からもフックによるデータの参照は可能です。
// import { Link } from 'react-router-dom';
import { Link, useLoaderData } from 'react-router-dom';
import { ContactType } from '../types';
export const Contacts: FC = () => {
const { contacts } = useLoaderData() as { contacts: ContactType[] };
return (
<nav>
{/* <ul>
<li>
<Link to={`/contacts/1`}>Your Name</Link>
</li>
<li>
<Link to={`/contacts/2`}>Your Friend</Link>
</li>
</ul> */}
{contacts.length ? (
<ul>
{contacts.map(({ id, first, last, favorite }) => (
<li key={id}>
<Link to={`contacts/${id}`}>
{first || last ? (
<>
{first} {last}
</>
) : (
<i>No Name</i>
)}{' '}
{favorite && <span>★</span>}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
);
};
これで、React Routerがloader
により読み込んだデータは、ルートとコンポーネントに連携されます。もっとも、今はロードすべき記録のデータがないので、情報のリストは空です。
HTMLフォームでデータを送る
HTMLフォームは、<a>
要素のリンクと同じく、ブラウザでURLを遷移します。さらに、リクエストのメソッド(GET
またはPOST
)とPOST
の場合ボディを変更できることが、リンクとの違いです。標準のHTMLによりフォームのデータは、POSTではリクエストボディ、GETならURLSearchParams
としてサーバーに送られます。React Routerがクライアントサイドルーティングを用いてデータを送る先は、ルートaction
です。
まだ、フォームの送信にクライアントサイドルーティングは使っていません。そのため、サイドバー上部にある新規記録作成の[New]ボタンを押すと、エラーが示されます(図006)。
export const SearchForm: FC = () => {
return (
<div>
<form method="post">
<button type="submit">New</button>
</form>
</div>
);
};
図006■POSTリクエストがサーバーに送れないことを示すエラー
新規の記録をつくる
クライアントサイドルーティングで新規の記録をつくる手順はつぎの3つです。
- ルートに
action
関数を定める。 -
<form>
要素を<Form>
コンポーネントに差し替える。 -
router
のRoot
オブジェクトにaction
プロパティを加える。
ルートにaction
関数を定める
まず、src/routes/root.tsx
モジュールにaction
関数を定めて、export
しましょう。
// import type { LoaderFunction } from "react-router-dom";
import type { ActionFunction, LoaderFunction } from "react-router-dom";
// import { getContacts } from '../contacts';
import { createContact, getContacts } from '../contacts';
export const action: ActionFunction = async () => {
const contact = await createContact();
return { contact };
};
<form>
要素を<Form>
コンポーネントに差し替える
つぎに、src/components/search-form.tsx
モジュールの<form>
要素をReact Routerの<Form>
コンポーネントに差し替えてください。
import { Form } from 'react-router-dom';
export const SearchForm: FC = () => {
return (
<div>
{/* <form method="post"> */}
<Form method="post">
<button type="submit">New</button>
{/* </form> */}
</Form>
</div>
);
};
router
のRoot
オブジェクトにaction
プロパティを加える
そのうえで、src/App.tsx
モジュールにaction
関数をimport
して、router
のRoote
オブジェクトにaction
プロパティとして加えなければなりません。
import {
action as rootAction,
} from './routes/root';
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
],
},
]);
なお、RouteObject
のloader
やaction
をはじめとするプロパティの型は「Type declaration」で確かめられます。
これで、サイドバー上部の[New]ボタンで新たな記録が作られ、デフォルトの名前「No Name」が加わるでしょう(図007)。ただし、まだ記録を削除する処理は書いていません。[New]ボタンを連打すると、「No Name」が並んだ見苦しい表示になるので、お気をつけください。
図007■[New]ボタンで新たな記録が加えられる
もっとも、サイドバーに示された名前(「No Name」)をクリックしても、右側にはContact
コンポーネント(src/routes/contact.tsx
)に決め打ちした情報(contact
)が表れるだけです。それでも、URLの:contactId
には、新規作成されたIDが挿入されていることにご注目ください。
ところで、サイドバー上部の[New]ボタン(src/components/search-form.tsx
)には、type="submit"
が定められているだけです。記録のリスト(src/components/contacts.tsx
)についても、データを状態に収めているわけではありません。記録のデータはどのように更新されたのでしょう。
export const SearchForm: FC = () => {
return (
<div>
<Form method="post">
<button type="submit">New</button>
</Form>
</div>
);
};
<Form>
コンポーネントはsubmit
でリクエストをサーバーでなく、ルートのaction
に送ります。そして、POSTが意味するのは、何らかのデータを変更するということです。そのため、React Routerは、action
が終わると同時にページのデータを再検証します。すると、useLoaderData
フックによる更新が実行されて、ユーザーインタフェースはデータと同期するのです。
loader
関数の引数でparams
オブジェクトを受け取る
新規の記録に固有の:contactId
が定められました。その動的なセグメントには、ID以外にもそれぞれ違った値が与えられます。これらが、URLパラメータ(params
オブジェクト)です。
URLパラメータはloader
関数の引数に、プロパティparams
として渡されます。そのとき、キーとなるのが動的セグメントです。このアプリケーションで面会記録のセグメントは:contactId
としました。すると、キーはparams.contactId
として取り出せます。これが、記録のリストからデータを探して特定するとき役立つのです。
loader
関数を加えるのは、モジュールsrc/routes/contact.tsx
です。params
から得たcontactId
を、データモジュールsrc/contacts.ts
の関数getContact
に渡すと表示すべき記録のデータ(contact
)が返ります。コンポーネントContact
でデータを取り出すのがuseLoaderData
です。
// import { Form } from 'react-router-dom';
import { Form, useLoaderData } from 'react-router-dom';
import type { LoaderFunction } from 'react-router-dom';
import { getContact } from '../contacts';
// import type { ContactType } from '../types';
import type { ContactType, Params } from '../types';
export const loader: LoaderFunction<Params> = async ({
params: { contactId },
}) => {
const contact = await getContact(contactId);
return { contact };
};
export const Contact: FC = () => {
/* const contact: ContactType = {
first: 'Your',
last: 'Name',
avatar: 'https://placekitten.com/g/200/200',
twitter: 'your_handle',
notes: 'Some notes',
favorite: true,
}; */
const { contact } = useLoaderData() as { contact: ContactType };
};
loader
関数は、モジュールsrc/App.tsx
で、router
の子Route
オブジェクトにloader
プロパティとして定めなければなりません。
// import { Contact } from "./routes/contact";
import { Contact, loader as contactLoader } from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
],
},
]);
これで、決め打ちしたデータでなく、動的セグメント(:contactId
)に応じた情報がサイドバーの右に表示されます(図008)。もっとも、動的セグメントのキー以外、データは実質的に空です。
図008■動的セグメントに一致した情報がサイドバーの右に表示される
データを更新する
そこで、新規記録のデータを書き替えられるようにします。
新規記録データの編集画面をつくる
新規記録データの編集画面をつくって、情報が書き加えられるようにしましょう。新たな記録編集のモジュールは、src/routes/edit.tsx
です。表示する情報は、useLoaderData
で取り出します。
import type { FC } from 'react';
import { Form, useLoaderData } from 'react-router-dom';
import type { ContactType } from '../types';
export const EditContact: FC = () => {
const {
contact: { first, last, twitter, avatar, notes },
} = useLoaderData() as { contact: ContactType };
return (
<Form method="post" id="contact-form">
<p>
<span>Name</span>
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={first}
/>
<input
placeholder="Last"
aria-label="Last name"
type="text"
name="last"
defaultValue={last}
/>
</p>
<label>
<span>Twitter</span>
<input
type="text"
name="twitter"
placeholder="@jack"
defaultValue={twitter}
/>
</label>
<label>
<span>Avatar URL</span>
<input
placeholder="https://example.com/avatar.jpg"
aria-label="Avatar URL"
type="text"
name="avatar"
defaultValue={avatar}
/>
</label>
<label>
<span>Notes</span>
<textarea name="notes" defaultValue={notes} rows={6} />
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
};
loader
関数は、面会記録表示のモジュールsrc/routes/contact
から使い回すことにしました(実際の開発では、ルートごとに別に定めるのが通常です)。モジュールsrc/App.tsx
のrouter
にContact
コンポーネントと同じchildren
として加え、子Route
オブジェクトのloader
プロパティに定めましょう。
import { EditContact } from './routes/edit';
const router = createBrowserRouter([
{
path: '/',
children: [
{
path: 'contacts/:contactId/edit',
element: <EditContact />,
loader: contactLoader,
},
],
},
]);
記録表示の画面で[Edit]ボタンをクリックすると、EditContact
コンポーネントの編集画面に遷移します(図009)。これは、記録表示のContact
コンポーネントで、ボタン(<button type="submit">
)が含まれる<Form>
コンポーネントに、action
プロパティとして遷移先のセグメントedit
が定められていたからです(contacts/:contactId/edit
)。
プロパティの働きは、標準HTMLにおける<form>
要素のaction
属性と基本的に変わりません。ただし、デフォルトのURLは相対パスとなることにご注意ください。
export const Contact: FC = () => {
return (
<div id="contact">
<div>
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
</div>
</div>
</div>
);
};
図009■記録編集の画面が表示される
FormData
で記録のデータを更新する
記録更新のためには、書き替えたデータをルートのaction
に送らなければなりません。モジュールsrc/routes/edit.tsx
に、action
関数をつぎのように加えましょう。データモジュールsrc/contacts.ts
からimport
したupdateContact
に、contactId
と更新値のFormData
オブジェクトupdates
を渡してデータが書き替わります。
// import { Form, useLoaderData } from 'react-router-dom';
import { Form, useLoaderData, redirect } from 'react-router-dom';
import type { ActionFunction } from 'react-router-dom';
import { updateContact } from '../contacts';
// import type { ContactType } from '../types';
import type { ContactType, Params } from '../types';
export const action: ActionFunction<{
request: Request;
params: Params;
}> = async ({ request, params: { contactId } }) => {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(contactId, updates);
return redirect(`/contacts/${contactId}`);
};
環境によっては、TypeScriptのつぎのような警告が示されるかもしれません。メッセージのとおり、Object.fromEntries()
はECMAScript 2019からの仕様です。
Property 'fromEntries' does not exist on type 'ObjectConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2019' or later.
この場合、tsconfig.json
のcompilerOptions
で、lib
の対応をes2019
に書き替えなければなりません。
{
"compilerOptions": {
"lib": [
"dom",
// "es2015"
"es2019"
],
}
}
action
関数はモジュールsrc/App.tsx
のrouter
で、children
の子Route
オブジェクトにaction
として定めてください。
// import { EditContact } from './routes/edit';
import { action as editAction, EditContact } from './routes/edit';
const router = createBrowserRouter([
{
path: '/',
children: [
{
path: 'contacts/:contactId/edit',
element: <EditContact />,
loader: contactLoader,
action: editAction,
},
],
},
]);
[Save]ボタンはEditContact
コンポーネントの<Form method="post">
に、<button type="submit">
要素で加えられていました。したがって、POSTリクエストはaction
に送られて、データが更新されるのです(図010)。各モジュールのコードと動きは、以下のサンプル003でお確かめください。
export const EditContact: FC = () => {
return (
<Form method="post" id="contact-form">
<p>
<button type="submit">Save</button>
</p>
</Form>
);
};
図010■記録のデータが更新された
サンプル003■React + TypeScript: React Router Tutorial 03
データはどのように更新されるか
モジュールsrc/routes/edit.tsx
のaction
関数で、データがどのように更新されるか、少し詳しく見ていきましょう。
標準HTMLでフォーム(<form>
)を送信(submit
)すると、FormData
がつくられます。そして、リクエストのボディとしてサーバーに送られるのです。React Routerではサーバーではなく、action
関数の引数オブジェクトにrequest
プロパティで渡されます。Request
オブジェクトからFormData
オブジェクト(Promise
)を得て返すのが、request.formData
メソッドです。
export const action: ActionFunction<{
request: Request;
params: Params;
}> = async ({ request, params: { contactId } }) => {
const formData = await request.formData();
};
さて、EditContact
コンポーネントの<input type="text">
要素にはname
属性が与えられていました。
export const EditContact: FC = () => {
const {
contact: { first, last, twitter, avatar, notes },
} = useLoaderData() as { contact: ContactType };
return (
<Form method="post" id="contact-form">
<p>
<span>Name</span>
<input
type="text"
name="first"
/>
<input
type="text"
name="last"
/>
</p>
</Form>
);
};
すると、FormData.get()
メソッドを用いて、formData.get(name)
の構文でそれぞれの値が得られます。もっとも、フィールドが多くなると、変数の数も増えてしまって煩雑です。
const formData = await request.formData();
const firstName = formData.get('first');
const lastName = formData.get('last');
console.log(firstName, lastName);
そこで、Object.fromEntries()
メソッドを使いました。返されるのは、キーと値の組みをプロパティとしていくつでももてるひとつのオブジェクト(updates
)です。ここでご紹介したAPIやメソッドは、React Router独自の実装ではなく、Webプラットフォームの仕様であることにご注目ください。
export const action: ActionFunction<{
request: Request;
params: Params;
}> = async ({ request, params: { contactId } }) => {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
};
このupdates
オブジェクトをcontactId
とともに関数updateContact
に渡せば、記録データは更新されます。
await updateContact(contactId, updates);
モジュールsrc/routes/edit.tsx
のaction
関数が返すのは、redirect
の戻り値で、Response
オブジェクトです。関数はリクエスト(request
)を受け取るので、レスポンスが返されるのは自然でしょう。
引数のパスにページを遷移するのがredirect
です。クライアントルーティングでなければ、POSTリスエストのあとサーバーがページを移すと、新たなデータで描き替わります。この振る舞いは、React Routerでも変わりません。action
が終わると、ページのデータは再検証されるのです。こうして、フォームデータの[Save](submit
)により、サイドバーの情報が更新されました。
export const action: ActionFunction<{
request: Request;
params: Params;
}> = async ({ request, params: { contactId } }) => {
return redirect(`/contacts/${contactId}`);
};
新規記録を作成したら編集ページに遷移する
redirect
でページは遷移できるのですから、[New]ボタンで新規記録をつくったとき、データが空っぽのまま済ませるのはいただけません。自動的にデータ編集画面(/contacts/:contactId/edit
)に移るべきでしょう。
// import { Outlet } from 'react-router-dom';
import { Outlet, redirect } from 'react-router-dom';
export const action: ActionFunction = async () => {
// return { contact };
return redirect(`/contacts/${contact.id}/edit`);
};
選ばれているリンクのスタイルを変える
今のままでは、サイドバーのリストからどの記録を選んで(アクティブ)、右側の情報が表示されているのかわかりません。リンクがアクティブかどうか調べられるのが<NavLink>
コンポーネントです。<Link>
と差し替えれば、isActive
(ブール値)でアクティブかどうかが調べられます。
つぎのコードは、アクティブなリンクのスタイルをCSSのクラス(className
)で切り替えました(図011)。なお、isPending
(ブール値)は、アクティブになるまでの経過状態です。遷移に待ちが生じる場合の扱いに役立つでしょう。
// import { Link, useLoaderData } from 'react-router-dom';
import { Link, NavLink, useLoaderData } from 'react-router-dom';
export const Contacts: FC = () => {
return (
<nav>
{contacts.length ? (
<ul>
{contacts.map(({ id, first, last, favorite }) => (
<li key={id}>
{/* <Link to={`contacts/${id}`}> */}
<NavLink
to={`contacts/${id}`}
className={({ isActive, isPending }) =>
isActive ? 'active' : isPending ? 'pending' : ''
}
>
{/* </Link> */}
</NavLink>
</li>
))}
</ul>
) : (
)}
</nav>
);
};
図011■アクティブなリンクのスタイルをCSSで変更した
グローバルな読み込み待ちのユーザーインタフェース
React Routerでユーザーの操作によりアプリケーションのページを遷移したとき、つぎのページのデータが読み込まれるまで、前のページは表示されたままになります。すると、アプリケーションが反応していないとユーザーに感じさせるかもしれません。ページが読み込み中であることをユーザーに知らせるとよいでしょう。
React Routerは、動的なアプリケーションがつくれるよう、背後の状態を管理しています。今回使うのは、ページナビゲーションの情報を調べるuseNavigation
フックです。
const navigation = useNavigation();
navigation.state
は、現在のナビゲーションの状態をつぎの3つの文字列の値いずれかで示します。
-
'idle'
: 読み込み待ちはない。 -
'submitting'
: ルートのaction
が呼び出され、POSTなどでフォーム送信している。 -
'loading'
: つぎのルートのloader
が呼び出され、遷移先ページをレンダーしている。
モジュールsrc/routes/root.tsx
でサイドバー右側の<div>
要素(id="detail"
)に、state
が'loading'
の場合のCSSクラス(className
)を与えました。アニメーションでフェードアウトするクラス(loading
)です。これで、画面が読み込み中であることは伝わるでしょう(後掲サンプル004参照)。
// import { Outlet, redirect } from 'react-router-dom';
import { Outlet, redirect, useNavigation } from 'react-router-dom';
export const Root: FC = () => {
const { state } = useNavigation();
return (
<>
{/* <div id="detail"> */}
<div id="detail" className={state === 'loading' ? 'loading' : ''}>
<Outlet />
</div>
</>
);
};
WebサーバーとAPIのやり取りをするデータモジュールsrc/contacts.ts
には、クライアントサイドキャッシュが備わっていることにご注意ください。そのため、すでに開いた記録の情報に遷移すれば、読み込み待ちもなくただちに切り替わります。再び試すときは、アプリケーションをロードし直し、場合によってはキャッシュも削除してください。
これは、React Routerの振る舞いではありません。ルートを変えれば、再度の遷移かどうかにかかわらず、データは読み込み直されます。ただし、サイドバーのようにナビゲーションしてもそのまま残るルートは、ローダーが呼び出されません。
記録を削除する
ようやく加えるのが、記録を削除する機能です。
記録の[Delete]ボタンはContact
コンポーネントに備わっている
記録を削除する[Delete]ボタン(<button type="submit">
)は、すでにsrc/routes/contact.tsx
モジュールのContact
コンポーネントに備わっています。そして、親の<Form>
コンポーネントに添えられたaction
プロパティの値は、"destroy"
です(親ルートcontact/:contactId
に対する相対パス)。したがって、confirm
のダイアログで[OK]をクリックすれば、contact/:contactId/destroy
に遷移することになります。
export const Contact: FC = () => {
return (
<div id="contact">
<div>
<div>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (!confirm('Please confirm you want to delete this record.')) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
};
もっとも、ルートcontact/:contactId/destroy
も、遷移先のコンポーネントもまだつくっていません(ページが見つからないというエラーになります)。
新たなdestroy
のモジュールにaction
関数を定める
新しくつくるモジュールsrc/routes/destroy.tsx
に、action
関数を定めましょう。関数内で呼び出すdeleteContact
はデータモジュールsrc/contacts.ts
からimport
しました。引数は、削除すべき記録を特定するcontactId
です。
import { ActionFunction, redirect } from 'react-router-dom';
import { deleteContact } from '../contacts';
import { Params } from '../types';
export const action: ActionFunction<Params> = async ({
params: { contactId },
}) => {
if (contactId) {
await deleteContact(contactId);
}
return redirect('/');
};
router
に与えるRoute
オブジェクト配列にdestroy
ルートを加える
あとは、モジュールsrc/App.tsx
のrouter
で、パス(path
)contacts/:contactId/destroy
のRoute
オブジェクトをルート(/
)パスの子(children
)として加えればよいでしょう。なお、前掲src/routes/destroy.tsx
モジュールのaction
関数は、redirect
によるルートパス(/
)への遷移を返しました。そのため、表示するコンポーネントのelement
プロパティは定めません。
import { action as destroyAction } from './routes/destroy';
const router = createBrowserRouter([
{
path: '/',
children: [
{
path: 'contacts/:contactId/destroy',
action: destroyAction,
},
],
},
]);
これで、Contact
コンポーネントの編集画面で[Delete]ボタンをクリックすれば、記録はリストから削除されるはずです(サンプル004)。
サンプル004■React + TypeScript: React Router Tutorial 04
ページのデータはどのように削除・更新されるか
[Delete]ボタンのクリックにより、モジュールsrc/routes/destroy.tsx
のaction
関数がどう呼び出され、データはどのように削除・更新されるのか、改めて確かめましょう。
-
<Form>
コンポーネントは新しいPOSTリクエストをサーバーには送りません。ブラウザの動き気にしたがいつつ、POSTリクエストはクライアントサイドルーティングでつくられます。 -
<Form action="destroy">
が合致するルートは新しいcontacts/:contactId/destroy
です。リクエストはこのルートに送られます。 - そして呼び出される
action
関数が担うのは、データの削除(deleteContact
)とルートパス(/
)への遷移(redirect
)です。すると、React Routerはページ上のすべてのデータのloader
を呼び出し、最新の値を取得します(いわゆる「再検証」)。useLoaderData
の返す新しい値により、コンポーネントは更新されるのです。
新たな<Form>
コンポーネントにルートを定め、action
関数さえ加えれば、あとはReact Routerがやってくれました。
さて、ここまでがReact Router公式「Tutorial」の中身6割方の解説です。<Form>
コンポーネントにloader
とaction
を組み合わせてクライアントサイドルーティングが実現されました。後半は「React + React Router + TypeScript: チュートリアル[02]」として公開予定です。