2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React + React Router + TypeScript: チュートリアル[02] 遷移の細かな制御と検索の機能

Last updated at Posted at 2024-03-22

React Routerのv6.4から採り入れらた新しいData API(「Using v6.4 Data APIs」参照)を使った公式サイト「Tutorial」の作例にもとづくチュートリアルの後編です。TypeScriptを用い、モジュールの組み立てやコードは手直ししました。解説も書き改めています。さらに、要所ごとにStackBlitzのコードサンプルを掲げました。各モジュールのコードが開けて見られ、動作を確認しつつ、書き替えて試すこともできるでしょう。

コンテキストに応じたエラー

遷移の文脈に応じたエラー画面は、ユーザーに役立つことが少なくありません。たとえば、モジュールsrc/routes/destroy.tsxactionで、あえてエラーを投げてみましょう。記録の編集画面で[Delete]ボタンを押して[OK]をクリックすると、エラーが示されます(図001)。

src/routes/destroy.tsx
	params: { contactId },
export const action: ActionFunction<Params> = async ({
}) => {
	throw new Error('oh dang!');

};

図001■編集画面で[Delete]を実行するとエラーが示される

qiita_2402001_001.png

この画面は、前回ルートパス(/)のRouteオブジェクトにerrorElementとして与えたErrorPageコンポーネントです。これで、エラーが起きたことはわかります。けれど、ユーザーはもとの画面をロードし直すことくらいしかできません。

コンテキストに応じたエラー画面を、contacts/:contactId/destroyパスに加えましょう。子Routeオブジェクトでも、定めるプロパティはerrorElementです。編集画面で[Delete]を実行すると、表示されるのは新たなエラー画面に変わりました(図002)。

src/App.tsx
const router = createBrowserRouter([
	{
		path: '/',
		element: <Root />,

		children: [

			{
				path: 'contacts/:contactId/destroy',
				action: destroyAction,
				errorElement: <div>Oops! There was an error.</div>,
			},
		],
	},
]);

図002■コンテキストに応じたエラー画面が表示される

qiita_2402001_002.png

もっとも、表示されるのは子ルート(contacts/:contactId/destroy)のRouteオブジェクトにerrorElementとして直書きした要素(<div>)です。実際には、ユーザーに役立つ機能を備えたコンポーネントに差し替えなければなりません。けれど、今回その実装については省きます。

理解いただきたいのは、子ルートのエラーは遡って、もっとも近い親のerrorElementが描画されるということです。そして、子ルート自体にもerrorElementは定められ、コンテキストに応じたエラー画面として表示できます。

インデックスルート

アプリケーションを立ち上げて開いたルートパス(/)は、サイドバーの右側が何もない空白です。ルートがchildrenをもつとき、はじめは親のパスで表示されます。すると、<Outlet>には何も描画されません。パスに合致する子ルートがないからです。

インデックスルートを用いれば、親のデフォルト子ルートとして描くことができます。まず、インデックスルートのモジュール(src/routes/index.tsx)をつくりましょう。

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オブジェクトのindextrueを与えるのが、親ルートパス(/)のデフォルト子ルートとする定めです。他の子ルートは親とはパスが異なりますので、<Outlet>には描画されません(逆に、他の子ルートのパスに遷移すると、親のパスのインデックスルートは表示されなくなるということです)。

src/App.tsx
import { Index } from './routes/index';

const router = createBrowserRouter([
	{
		path: '/',
		element: <Root />,

		children: [
			{ index: true, element: <Index /> },

		],
	},
]);

アプリケーションのルートのパス(/)を開くと、サイドバーの右側に子のインデックスルートが表紙されるようになりました(図003)。

図003■ルートのパス(/)でサイドバーの右側に子のインデックスルートが表示される

qiita_2402001_003.png

取り消しボタン

編集画面の[Cancel]ボタンがまだ動いていませんでした。機能はブラウザの[戻る]ボタンと同じです。何もせずに、もとの画面に戻します。ボタンのonClickハンドラに用いるのは、React RouterのuseNavigateフックが返す関数(navigate)です。引数に-1を与えれば、履歴をひとつ戻ります。

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

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

src/routes/root.tsx
// 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とフォームの状態の同期に問題がふたつあることに気づくでしょう。

  1. 検索後に[戻る]ボタンを押したとき:
    • フォームの検索フィールドには入力した値が残っている。
    • サイドバーの記録リストはフィルタリングされていない。
  2. 検索したあとにページを読み込み直したとき:
    • フォームの検索フィールドは値が消える。
    • サイドバーの記録リストはフィルタリングされたまま。

先に、上記2の問題です。モジュールsrc/routes/root.tsxloaderからqの値をオブジェクトに加えて返します。

src/routes/root.tsx
export const loader: LoaderFunction = async ({ request }) => {

	// return { contacts };
	return { contacts, q };
};

コンポーネントSearchForm(src/components/search-form.tsx)は、useLoaderDataqの値を得て、<input>要素のdefaultValueに与えればよいのです。これで、検索語にページをリロードしても、検索フィールドのクエリ値は消えません。

src/components/search-form.tsx
// 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と同期するようになりました。

src/components/search-form.tsx
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ハンドラから呼び出せばよいでしょう。

src/components/search-form.tsx
// 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})にしてありました。

src/components/search-form.tsx
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■検索中入力フィールド左端に検索スピナーが表れる

qiita_2402001_004.png

履歴スタックを管理する

フォームは検索フィールドに入力するたびに送信されるようになりました。けれど、履歴スタックに積み上がります。"Bill"とタイプし、ひと文字ずつ消し去ると、新たに7つの履歴がスタックに残ってしまうのです(図005)。ユーザーにとって好ましいこととはいえません。

図005■入力ひと文字ごとに履歴スタックが加わる

qiita_2402001_005.png

これを避けるには、履歴スタックにつぎのページを追加するのでなく、現行の入力と置き替えることです。その場合、submitの第2引数オブジェクトにreplaceオプションを与えてください。

src/components/search-form.tsx
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フックです。loaderactionと、ナビゲーションはなしにやりとりできます。各記録につけるお気に入り(★)ボタンは適した例でしょう。記録の作成・削除もページ遷移も要りません。表示されているページのデータを変更したいだけです。

Favoriteコンポーネント(src/routes/contact.tsx)の<Form><fetcher.Form>に置き替えてください。コンポーネントのボタン(<button>)には、name="favorite"が与えられています。このキーで送られるformDataの値(value)は'false' | 'true'<fetcher.Form>からの送信はmethod="post"です。

src/routes/contact.tsx
// 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送信されます。actionrequestから取り出したformDataと、データモジュールの関数updateContactによりデータを更新するだけです。

src/routes/contact.tsx
// 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.tsxrouterchildrenに、子Routeオブジェクトのactionとして新たに定めた関数を加えてください。

src/App.tsx
// 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■記録の名前右側のボタンでお気に入り表示が切り替わる

qiita_2402001_009.png

<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更新」の手法を用います。fetcheractionに送るのは更新するフォームデータ(formData)です。したがって、fetcher.formDataから新しい値は得られます。状態の更新はネットワーク通信の終わりを待つまでもありません。更新できなかったとき、ユーザーインタフェースがデータをもとに戻せばよいのです。

src/routes/contact.tsx
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■記録が存在しない動的セグメントでルートを遷移する

qiita_2402001_006.png

ただ、nullのプロパティavatarが読み込めないと告げられてもよくわかりません。実際には、src/routes/contact.tsxloaderに渡されたparamscontactIdが記録データの中に見つからなかったということです。そのため、Contactコンポーネントから呼び出されたuseLoaderDataは有効なcontactを返せず(null)、avatarプロパティも取り出せませんでした。

src/routes/contact.tsx
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)ということです。コードから明示的にエラーを投げれば、内容がわかりやすく示せます。

src/routes/contact.tsx
export const loader: LoaderFunction<Params> = async ({
	params: { contactId },
}) => {

	if (!contact) {
		throw new Response('', {
			status: 404,
			statusText: 'Not Found',
		});
	}

};

図008■404 Not Foundエラー画面が表示された

qiita_2402001_007.png

パスなしのルート

前項のエラーページにはサイドバーがありません。サイドバーの右に表示したいときは、ルート(/)のRouteオブジェクトにchildren配列として加えて、Rootコンポーネントの<Outlet />に表示します。このとき、つぎのように入れ子になったRouteオブジェクトには、errorElementchildrenだけでpathがありません。これが今回のルート構成を実現するパスなしのルートという手法です。

src/App.tsx
const router = createBrowserRouter([
	{
		path: '/',
		element: <Root />,

		children: [
			{
				errorElement: <ErrorPage />,
				children: [
					{ index: true, element: <Index /> },

				],
			},
		],
	},
]);

サイドバーの右にNot Foundエラーが表示されるので、エラーは気にせず別の記録を選ぶこともできるようになりました(図009)。まとめたStackBlitzのコードは、以下のサンプル003のとおりです。

図009■サイドバーの右にNot Foundエラーが表示される

qiita_2402001_008.png

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

JSXでルートを定める

ルート(router)は、オブジェクト以外に、JSXの構文でも定められます。そのJSXを引数として受け取るのがcreateRoutesFromElementsです。戻り値は、createBrowserRouterの引数に渡します。ですから、機能はどちらの構文でも変わりません。お好みでお選びください。

JSXではルートは<Route>コンポーネントで、プロパティはその属性として与えます。ただし、childrenプロパティはないので、<Route>コンポーネントを入れ子にしてください。

前掲サンプル003のルートをJSXに書き替える場合、修正するモジュールはsrc/App.tsxです。JSXに書き改めたコードは、サンプル004に掲げました。

src/App.tsx
// 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

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?