7
1

React + React Router + TypeScript: チュートリアル[01] loaderとactionでデータを作成・編集・削除する

Last updated at Posted at 2024-02-26

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つのライブラリを使います。

そのため、つぎのようにセットアップしてください。

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.tssrc/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コンポーネントはこのあとでつくります。

src/routes/root.tsx
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がpathelementは表示するコンポーネントです。戻り値のルーター(router)は<RouterProvider>コンポーネントのrouterプロパティに与えます。

src/App.tsx
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} />
	);
};
src/index.tsx
root.render(
	<StrictMode>
		{/* <App name="StackBlitz" /> */}
		<App />
	</StrictMode>
);

新たに定めるSearchFormコンポーネントのモジュールsrc/components/search-form.tsxのコードはつぎのとおりです。

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がそのコードです。

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です。

src/routes/root.tsx
import { Contacts } from "../components/contacts";

export const Root: FC = () => {
	return (
		<>
			<div id="sidebar">

				<Contacts />
			</div>

		</>
	);
};

図001■ルーティングのルート画面に表示されたサイドバー

qiita_2401001_001.png

サンプル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エラー画面

qiita_2401001_002.png

React Routerは、ルーティングでつぎのようなときに起こるエラーを拾います。

  • レンダリング
  • データの読み込み
  • データの変更

エラーを確かめるフックはuseRouteErrorです。自作のエラーページのモジュールsrc/components/error-page.tsxをつぎのように定めましょう。ルーティングの本題に関わらないコードの中身は、かなり手を抜きました。

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.tsxrouterのルート(/)Routeオブジェクトに、前掲図002のエラーメッセージで示されていたとおりerrorElementとして加えます。これで、Not Foundのエラーは、自作のコンポーネントで表示されるでしょう(図003)。

src/App.tsx
import { ErrorPage } from './components/error-page';

const router = createBrowserRouter([
	{
		path: '/',
		element: <Root />,
		errorElement: <ErrorPage />,
	},
]);

図003■自作のコンポーネントで表示されたNot Foundエラー

qiita_2401001_003.png

記録情報を表示するルートのインタフェース

アプリケーションに備えるのは、面会者(コンタクト)の情報を記録して表示する機能です。サイドバーの右側、メイン画面に加える情報表示のモジュールsrc/routes/contact.tsxはつぎのように定めます。記録(contact)のデータは、今のところ決め打ちです。

src/routes/contact.tsx
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モジュールのrouterRouteオブジェクトとしてつぎのように加えます。pathに定めたURLの中でコロン(:)が頭についたセグメント(:contactId)は「動的セグメント」の記述です。/contacts/1に遷移すれば、:contactId1と合致してルーティングされ、情報表示画面(Contactコンポーネント)が開きます(図004)。変数のように動的に変化するわけです。

src/App.tsx
import { Contact } from './routes/contact';

const router = createBrowserRouter([

	{
		path: 'contacts/:contactId',
		element: <Contact />,
	},
]);

図004■動的セグメントに合致してルーティングされた情報表示画面

qiita_2401001_004.png

もっとも、コンポーネントはRootContactに差し替わっています。そのため、サイドバーは消えてしまいました。サイドバーを残すには、ContactRootの入れ子にしなければなりません。そのとき、routerに定めたRouteオブジェクトに用いるのがchildrenプロパティです。入れ子オブジェクトを配列に収めて与えてください。

src/App.tsx
const router = createBrowserRouter([
	{
		path: '/',
		element: <Root />,
		errorElement: <ErrorPage />,
		children: [
			{
				path: 'contacts/:contactId',
				element: <Contact />,
			},
		],
	},
	/* {
		path: 'contacts/:contactId',
		element: <Contact />,
	}, */
]);

もうひとつ、入れ子ルートをどこにレンダーするのか、親コンポーネント(Root)のJSXに加えなければなりません。子ルートを示すのは<Outlet>です。こうして、親ルートのサイドバーは残しながら、子ルートのURLに遷移して、情報表示画面が開けるようになりました(図005)。

src/routes/root.tsx
import { Outlet } from 'react-router-dom';

export const Root: FC = () => {
	return (
		<>

			<div id="detail">
				<Outlet />
			</div>
		</>
	);
};

図005■親のサイドバーは残しながら子ルートに遷移して記録情報を表示する

qiita_2401001_005.png

サイドバーのリンクは<a>要素で加えました(src/components/contacts.tsx)。すると、クリックしたとき、遷移するURLのドキュメントをそっくりブラウザにリクエストします。ブラウザの[デベロッパーツール]から開く[ネットワーク]タブで、リクエストの中身を確かめてみてください。

クライアントサイドルーティングを用いれば、アプリケーションはサーバーに別URLのドキュメント丸ごとはリクエストしません。そのとき使うのが、<Link>コンポーネントです。遷移先URLはtoプロパティに与えます。モジュールsrc/components/contacts.tsxのJSXで<a>要素を<Link>コンポーネントに差し替えましょう(サンプル002)。ユーザーインタフェースがただちにレンダーされ、[デベロッパーツール]の[ネットワーク]でドキュメント全体のリクエストはなくなったことがわかるはずです。

src/components/contacts.tsx
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> 個別の記録情報

ふたつのルートをデータの読み込みで連携するためには、loaderuseLoaderDataを用いて、3つの手順にしたがいます。

  • ルートにloader関数を定める。
  • routerRootオブジェクトに関数をloaderプロパティとして加える。
  • useLoaderDataでデータを参照してレンダーする。

ルートにloader関数を定める

loader関数はデータを読み込んで使うルートのモジュール(src/routes/root.tsx)に定めてexportしましょう。

src/routes/root.tsx
import type { LoaderFunction } from "react-router-dom";
import { getContacts } from "../contacts";

export const loader: LoaderFunction = async () => {
	const contacts = await getContacts(undefined);
	return { contacts };
};

routerRootオブジェクトにloaderプロパティを加える

loader関数はimportして、routerRouteオブジェクトにloaderプロパティとして定めなければなりません。

src/App.tsx
// 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)からもフックによるデータの参照は可能です。

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)。

src/components/search-form.tsx
export const SearchForm: FC = () => {
	return (
		<div>

			<form method="post">
				<button type="submit">New</button>
			</form>
		</div>
	);
};

図006■POSTリクエストがサーバーに送れないことを示すエラー

qiita_2401001_006.png

新規の記録をつくる

クライアントサイドルーティングで新規の記録をつくる手順はつぎの3つです。

  • ルートにaction関数を定める。
  • <form>要素を<Form>コンポーネントに差し替える。
  • routerRootオブジェクトにactionプロパティを加える。

ルートにaction関数を定める

まず、src/routes/root.tsxモジュールにaction関数を定めて、exportしましょう。

src/routes/root.tsx
// 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>コンポーネントに差し替えてください。

src/components/search-form.tsx
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>
	);
};

routerRootオブジェクトにactionプロパティを加える

そのうえで、src/App.tsxモジュールにaction関数をimportして、routerRooteオブジェクトにactionプロパティとして加えなければなりません。

src/App.tsx
import {

	action as rootAction,
} from './routes/root';

const router = createBrowserRouter([
	{
		path: "/",
		element: <Root />,
		errorElement: <ErrorPage />,
		loader: rootLoader,
		action: rootAction,
		children: [

		],
	},
]);

なお、RouteObjectloaderactionをはじめとするプロパティの型は「Type declaration」で確かめられます。

これで、サイドバー上部の[New]ボタンで新たな記録が作られ、デフォルトの名前「No Name」が加わるでしょう(図007)。ただし、まだ記録を削除する処理は書いていません。[New]ボタンを連打すると、「No Name」が並んだ見苦しい表示になるので、お気をつけください。

図007■[New]ボタンで新たな記録が加えられる

qiita_2401001_007.png

もっとも、サイドバーに示された名前(「No Name」)をクリックしても、右側にはContactコンポーネント(src/routes/contact.tsx)に決め打ちした情報(contact)が表れるだけです。それでも、URLの:contactIdには、新規作成されたIDが挿入されていることにご注目ください。

ところで、サイドバー上部の[New]ボタン(src/components/search-form.tsx)には、type="submit"が定められているだけです。記録のリスト(src/components/contacts.tsx)についても、データを状態に収めているわけではありません。記録のデータはどのように更新されたのでしょう。

src/components/search-form.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です。

src/routes/contact.tsx
// 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プロパティとして定めなければなりません。

src/App.tsx
// 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■動的セグメントに一致した情報がサイドバーの右に表示される

qiita_2401001_008.png

データを更新する

そこで、新規記録のデータを書き替えられるようにします。

新規記録データの編集画面をつくる

新規記録データの編集画面をつくって、情報が書き加えられるようにしましょう。新たな記録編集のモジュールは、src/routes/edit.tsxです。表示する情報は、useLoaderDataで取り出します。

src/routes/edit.tsx
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.tsxrouterContactコンポーネントと同じchildrenとして加え、子Routeオブジェクトのloaderプロパティに定めましょう。

src/App.tsx
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は相対パスとなることにご注意ください。

src/routes/contact.tsx
export const Contact: FC = () => {

	return (
		<div id="contact">

			<div>

				<div>
					<Form action="edit">
						<button type="submit">Edit</button>
					</Form>

				</div>
			</div>
		</div>
	);
};

図009■記録編集の画面が表示される

qiita_2401001_009.png

FormDataで記録のデータを更新する

記録更新のためには、書き替えたデータをルートのactionに送らなければなりません。モジュールsrc/routes/edit.tsxに、action関数をつぎのように加えましょう。データモジュールsrc/contacts.tsからimportしたupdateContactに、contactIdと更新値のFormDataオブジェクトupdatesを渡してデータが書き替わります。

src/routes/edit.tsx
// 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.jsoncompilerOptionsで、libの対応をes2019に書き替えなければなりません。

tsconfig.json
{

	"compilerOptions": {

		"lib": [
			"dom",
			// "es2015"
			"es2019"
		],

	}
}

action関数はモジュールsrc/App.tsxrouterで、childrenの子Routeオブジェクトにactionとして定めてください。

src/App.tsx
// 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でお確かめください。

src/routes/edit.tsx
export const EditContact: FC = () => {

	return (
		<Form method="post" id="contact-form">

			<p>
				<button type="submit">Save</button>

			</p>
		</Form>
	);
};

図010■記録のデータが更新された

qiita_2401001_010.png

サンプル003■React + TypeScript: React Router Tutorial 03

データはどのように更新されるか

モジュールsrc/routes/edit.tsxaction関数で、データがどのように更新されるか、少し詳しく見ていきましょう。

標準HTMLでフォーム(<form>)を送信(submit)すると、FormDataがつくられます。そして、リクエストのボディとしてサーバーに送られるのです。React Routerではサーバーではなく、action関数の引数オブジェクトにrequestプロパティで渡されます。RequestオブジェクトからFormDataオブジェクト(Promise)を得て返すのが、request.formDataメソッドです。

src/routes/edit.tsx
export const action: ActionFunction<{
	request: Request;
	params: Params;
}> = async ({ request, params: { contactId } }) => {
	const formData = await request.formData();

};

さて、EditContactコンポーネントの<input type="text">要素にはname属性が与えられていました。

src/routes/edit.tsx
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プラットフォームの仕様であることにご注目ください。

src/routes/edit.tsx
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に渡せば、記録データは更新されます。

src/routes/edit.tsx
await updateContact(contactId, updates);

モジュールsrc/routes/edit.tsxaction関数が返すのは、redirectの戻り値で、Responseオブジェクトです。関数はリクエスト(request)を受け取るので、レスポンスが返されるのは自然でしょう。

引数のパスにページを遷移するのがredirectです。クライアントルーティングでなければ、POSTリスエストのあとサーバーがページを移すと、新たなデータで描き替わります。この振る舞いは、React Routerでも変わりません。actionが終わると、ページのデータは再検証されるのです。こうして、フォームデータの[Save](submit)により、サイドバーの情報が更新されました。

src/routes/edit.tsx
export const action: ActionFunction<{
	request: Request;
	params: Params;
}> = async ({ request, params: { contactId } }) => {

	return redirect(`/contacts/${contactId}`);
};

新規記録を作成したら編集ページに遷移する

redirectでページは遷移できるのですから、[New]ボタンで新規記録をつくったとき、データが空っぽのまま済ませるのはいただけません。自動的にデータ編集画面(/contacts/:contactId/edit)に移るべきでしょう。

src/routes/root.tsx
// 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(ブール値)は、アクティブになるまでの経過状態です。遷移に待ちが生じる場合の扱いに役立つでしょう。

src/components/contacts.tsx
// 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で変更した

qiita_2401001_011.png

グローバルな読み込み待ちのユーザーインタフェース

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参照)。

src/routes/root.tsx
// 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に遷移することになります。

src/routes/contact.tsx
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です。

src/routes/destroy.tsx
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.tsxrouterで、パス(path)contacts/:contactId/destroyRouteオブジェクトをルート(/)パスの子(children)として加えればよいでしょう。なお、前掲src/routes/destroy.tsxモジュールのaction関数は、redirectによるルートパス(/)への遷移を返しました。そのため、表示するコンポーネントのelementプロパティは定めません。

src/App.tsx
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.tsxaction関数がどう呼び出され、データはどのように削除・更新されるのか、改めて確かめましょう。

  1. <Form>コンポーネントは新しいPOSTリクエストをサーバーには送りません。ブラウザの動き気にしたがいつつ、POSTリクエストはクライアントサイドルーティングでつくられます。
  2. <Form action="destroy">が合致するルートは新しいcontacts/:contactId/destroyです。リクエストはこのルートに送られます。
  3. そして呼び出されるaction関数が担うのは、データの削除(deleteContact)とルートパス(/)への遷移(redirect)です。すると、React Routerはページ上のすべてのデータのloaderを呼び出し、最新の値を取得します(いわゆる「再検証」)。useLoaderDataの返す新しい値により、コンポーネントは更新されるのです。

新たな<Form>コンポーネントにルートを定め、action関数さえ加えれば、あとはReact Routerがやってくれました。


さて、ここまでがReact Router公式「Tutorial」の中身6割方の解説です。<Form>コンポーネントにloaderactionを組み合わせてクライアントサイドルーティングが実現されました。後半は「React + React Router + TypeScript: チュートリアル[02]」として公開予定です。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1