アドレス帳
ここでは、連絡先を管理できる、小さくても機能豊富なアドレス帳アプリを構築します。データベースやその他の「本番環境対応」のものは使用しないため、React Router が提供する機能に集中できます。もし一緒に進める場合は30〜45分程度、そうでなければすぐに読み終えることができるでしょう。
もしよろしければ、React Routerチュートリアルのウォークスルー の動画もご覧ください 🎥
👉 このマークが表示されたら、アプリで何かをする必要があることを意味します!
それ以外は、あなたの情報とより深い理解のためのものです。それでは始めましょう。
セットアップ
👉 基本的なテンプレートを生成する
npx create-react-router@latest --template remix-run/react-router/tutorials/address-book
これは非常にシンプルなテンプレートを使用していますが、CSSとデータモデルが含まれているため、React Routerに集中できます。
👉 アプリを起動する
# アプリのディレクトリに移動
cd {アプリを置いた場所}
# まだインストールしていない場合は依存関係をインストール
npm install
# サーバーを起動
npm run dev
http://localhost:5173 を開くと、次のようなスタイルが適用されていない画面が表示されるはずです。
ルートルート
app/root.tsx
にあるファイルに注目してください。これは、私たちが"ルートルート" と呼ぶものです。これはUIで最初にレンダリングされるコンポーネントであり、通常はページのグローバルレイアウトと、デフォルトのエラー境界 が含まれています。
ルートコンポーネントのコードを表示するには、ここを展開してください
app/root.tsx import {
Form,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router" ;
import type { Route } from "./+types/root" ;
import appStylesHref from "./app.css?url" ;
export default function App () {
return (
<>
< div id = "sidebar" >
< h1 >React Router Contacts</ h1 >
< div >
< Form id = "search-form" role = "search" >
< input
aria-label = "Search contacts"
id = "q"
name = "q"
placeholder = "Search"
type = "search"
/>
< div
aria-hidden
hidden ={ true }
id = "search-spinner"
/>
</ Form >
< Form method = "post" >
< button type = "submit" >New</ button >
</ Form >
</ div >
< nav >
< ul >
< li >
< a href ={ `/contacts/1` } >Your Name</ a >
</ li >
< li >
< a href ={ `/contacts/2` } >Your Friend</ a >
</ li >
</ ul >
</ nav >
</ div >
</>
);
}
// Layoutコンポーネントは、ルートルートの特別なエクスポートです。
// これは、すべてのルートコンポーネント、HydrateFallback、およびErrorBoundaryのドキュメントの「アプリシェル」として機能します。
// 詳細については、https://reactrouter.com/explanation/special-files#layout-exportを参照してください。
export function Layout ({
children ,
} : {
children : React . ReactNode ;
}) {
return (
< html lang = "en" >
< head >
< meta charSet = "utf-8" />
< meta
name = "viewport"
content = "width=device-width, initial-scale=1"
/>
< link rel = "stylesheet" href ={ appStylesHref } />
</ head >
< body >
{ children }
< ScrollRestoration />
< Scripts />
</ body >
</ html >
);
}
// アプリケーションの最上位のエラー境界。アプリがエラーをスローしたときにレンダリングされます。
// 詳細については、https://reactrouter.com/start/framework/route-module#errorboundaryを参照してください。
export function ErrorBoundary ({
error ,
} : Route . ErrorBoundaryProps ) {
let message = "おっと!" ;
let details = "予期しないエラーが発生しました。" ;
let stack : string | undefined ;
if ( isRouteErrorResponse (error)) {
message = error.status === 404 ? "404" : "エラー" ;
details =
error.status === 404
? "リクエストされたページが見つかりませんでした。"
: error.statusText || details;
} else if (
import . meta .env. DEV &&
error &&
error instanceof Error
) {
details = error.message;
stack = error.stack;
}
return (
< main id = "error-page" >
< h1 > { message } </ h1 >
< p > { details } </ p >
{ stack && (
< pre >
< code > { stack } </ code >
</ pre >
) }
</ main >
);
}
コンタクトルートのUI
サイドバーの項目をクリックすると、デフォルトの404ページが表示されます。URL /contacts/1
に一致するルートを作成しましょう。
👉 コンタクトルートモジュールを作成する
mkdir app/routes
touch app/routes/contact.tsx
このファイルはどこにでも配置できますが、少し整理するために、すべてのルートを app/routes
ディレクトリ内に配置します。
ファイルベースルーティング を使用することもできます。
👉 ルートを設定する
新しいルートについてReact Routerに伝える必要があります。routes.ts
は、すべてのルートを設定できる特別なファイルです。
routes.ts import type { RouteConfig } from "@react-router/dev/routes" ;
import { route } from "@react-router/dev/routes" ;
export default [
route ( "contacts/:contactId" , "routes/contact.tsx" ),
] satisfies RouteConfig ;
React Routerでは、:
はセグメントを動的にします。これにより、次のURLが routes/contact.tsx
ルートモジュールに一致するようになりました。
/contacts/123
/contacts/abc
👉 コンポーネントUIを追加する
これは単なる要素の集まりです。自由にコピー/ペーストしてください。
app/routes/contact.tsx import { Form } from "react-router" ;
import type { ContactRecord } from "../data" ;
export default function Contact () {
const contact = {
first: "Your" ,
last: "Name" ,
avatar: "https://placecats.com/200/200" ,
twitter: "your_handle" ,
notes: "Some notes" ,
favorite: true ,
};
return (
< div id = "contact" >
< div >
< img
alt ={ `${ contact . first } ${ contact . last } avatar` }
key ={ contact.avatar }
src ={ contact.avatar }
/>
</ div >
< div >
< h1 >
{ contact.first || contact.last ? (
<>
{ contact.first } { contact.last }
</>
) : (
< i >No Name</ i >
) }
< Favorite contact ={ contact } />
</ h1 >
{ contact.twitter ? (
< p >
< a
href ={ `https://twitter.com/${ contact . twitter }` }
>
{ contact.twitter }
</ a >
</ p >
) : null }
{ contact.notes ? < p > { contact.notes } </ p > : null }
< div >
< Form action = "edit" >
< button type = "submit" >Edit</ button >
</ Form >
< Form
action = "destroy"
method = "post"
onSubmit ={ ( event ) => {
const response = confirm (
"Please confirm you want to delete this record."
);
if ( ! response) {
event. preventDefault ();
}
} }
>
< button type = "submit" >Delete</ button >
</ Form >
</ div >
</ div >
</ div >
);
}
function Favorite ({
contact ,
} : {
contact : Pick < ContactRecord , "favorite" >;
}) {
const favorite = contact.favorite;
return (
< Form method = "post" >
< button
aria-label ={
favorite
? "Remove from favorites"
: "Add to favorites"
}
name = "favorite"
value ={ favorite ? "false" : "true" }
>
{ favorite ? "★" : "☆" }
</ button >
</ Form >
);
}
リンクをクリックするか、/contacts/1
にアクセスすると、何も新しいものが表示されませんか?
ネストされたルートとアウトレット
React Router はネストされたルーティングをサポートしています。子ルートを親レイアウト内でレンダリングするには、親に Outlet
をレンダリングする必要があります。修正しましょう。app/root.tsx
を開き、中にアウトレットをレンダリングします。
👉 <Outlet />
をレンダリングする
app/root.tsx import {
Form,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router" ;
// 既存のインポートとエクスポート
export default function App () {
return (
<>
< div id = "sidebar" > { /* その他の要素 */ } </ div >
< div id = "detail" >
< Outlet />
</ div >
</>
);
}
これで、子ルートがアウトレットを通してレンダリングされるはずです。
クライアントサイドルーティング
お気づきかもしれませんが、サイドバーのリンクをクリックすると、ブラウザはクライアントサイドルーティングではなく、次のURLに対して完全なドキュメントリクエストを行っており、アプリが完全に再マウントされています。
クライアントサイドルーティングを使用すると、ページ全体をリロードせずにアプリのURLを更新できます。代わりに、アプリは新しいUIをすぐにレンダリングできます。<Link>
を使って実現しましょう。
👉 サイドバーの <a href>
を <Link to>
に変更してください
app/root.tsx import {
Form,
Link,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router" ;
// 既存のインポートとエクスポート
export default function App () {
return (
<>
< div id = "sidebar" >
{ /* その他の要素 */ }
< nav >
< ul >
< li >
< Link to ={ `/contacts/1` } >あなたの名前</ Link >
</ li >
< li >
< Link to ={ `/contacts/2` } >あなたの友達</ Link >
</ li >
</ ul >
</ nav >
</ div >
{ /* その他の要素 */ }
</>
);
}
ブラウザの開発者ツールのネットワークタブを開くと、ドキュメントをリクエストしなくなったことがわかります。
データのロード
URLセグメント、レイアウト、データは、多くの場合、組み合わさって(3つ組?)います。このアプリでもすでに確認できます。
URLセグメント コンポーネント データ / <App>
コンタクトのリスト contacts/:contactId <Contact>
個々のコンタクト
この自然な結合のため、React Routerには、ルートコンポーネントにデータを簡単に取り込むためのデータ規約があります。
まず、ルートルートでclientLoader
関数を作成してエクスポートし、データをレンダリングします。
👉 app/root.tsx
からclientLoader
関数をエクスポートし、データをレンダリングします
次のコードには型エラーが含まれています。次のセクションで修正します
app/root.tsx // 既存のインポート
import { getContacts } from "./data" ;
// 既存のエクスポート
export async function clientLoader () {
const contacts = await getContacts ();
return { contacts };
}
export default function App ({ loaderData }) {
const { contacts } = loaderData;
return (
<>
< div id = "sidebar" >
{ /* その他の要素 */ }
< nav >
{ contacts. length ? (
< ul >
{ contacts. map (( contact ) => (
< li key ={ contact.id } >
< Link to ={ `contacts/${ contact . id }` } >
{ contact.first || contact.last ? (
<>
{ contact.first } { contact.last }
</>
) : (
< i >名前なし</ i >
) }
{ contact.favorite ? (
< span >★</ span >
) : null }
</ Link >
</ li >
)) }
</ ul >
) : (
< p >
< i >コンタクトなし</ i >
</ p >
) }
</ nav >
</ div >
{ /* その他の要素 */ }
</>
);
}
以上です!React Routerは、このデータをUIと自動的に同期させます。サイドバーは次のようになります。
サーバーサイドレンダリング(SSR)を実行できるように、サーバーでデータをロードするのではなく、なぜ「クライアント」でデータをロードしているのか疑問に思うかもしれません。現在、コンタクトサイトはシングルページアプリ であるため、サーバーサイドレンダリングはありません。これにより、静的ホスティングプロバイダーへのデプロイが非常に簡単になりますが、React Routerが提供するさまざまなレンダリング戦略 について学ぶことができるように、SSRを有効にする方法について少し詳しく説明します。
型安全性
loaderData
プロパティに型を割り当てていないことに気づいたかもしれません。これを修正しましょう。
👉 App
コンポーネントに ComponentProps
型を追加してください
app/root.tsx // 既存のインポート
import type { Route } from "./+types/root" ;
// 既存のインポートとエクスポート
export default function App ({
loaderData,
} : Route.ComponentProps) {
const { contacts } = loaderData;
// 既存のコード
}
ちょっと待って、これは何?これらの型はどこから来たの?!
私たちは定義していませんが、なぜか clientLoader
から返した contacts
プロパティについて既に知っています。
これは、React Router が自動的な型安全性を実現するために、アプリ内の各ルートの型を生成している からです。
HydrateFallback
の追加
先ほど、サーバーサイドレンダリングを行わないシングルページアプリケーション に取り組んでいると述べました。react-router.config.ts
の中を見ると、これが単純なブール値で設定されていることがわかります。
react-router.config.ts import { type Config } from "@react-router/dev/config" ;
export default {
ssr: false ,
} satisfies Config ;
ページをリロードするたびに、アプリが読み込まれる前に白い画面が一瞬表示されることに気づき始めたかもしれません。クライアント側でのみレンダリングしているため、アプリの読み込み中にユーザーに表示するものがありません。
👉 HydrateFallback
エクスポートを追加する
HydrateFallback
エクスポートを使用すると、アプリがハイドレートされる(クライアントで初めてレンダリングされる)前に表示されるフォールバックを提供できます。
app/root.tsx // 既存のインポートとエクスポート
export function HydrateFallback () {
return (
< div id = "loading-splash" >
< div id = "loading-splash-spinner" />
< p >読み込み中、しばらくお待ちください...</ p >
</ div >
);
}
これで、ページをリロードすると、アプリがハイドレートされる前に、読み込みスプラッシュが一瞬表示されるようになります。
インデックスルート
アプリをロードしたとき、まだ連絡先ページにいない場合、リストの右側に大きな空白ページが表示されることに気づくでしょう。
ルートに子ルートがある場合、親ルートのパスにいるとき、子ルートが一致しないため、<Outlet>
は何もレンダリングしません。インデックスルート は、そのスペースを埋めるデフォルトの子ルートと考えることができます。
👉 ルートルートのインデックスルートを作成する
touch app/routes/home.tsx
app/routes.ts import type { RouteConfig } from "@react-router/dev/routes" ;
import { index, route } from "@react-router/dev/routes" ;
export default [
index ( "routes/home.tsx" ),
route ( "contacts/:contactId" , "routes/contact.tsx" ),
] satisfies RouteConfig ;
👉 インデックスコンポーネントの要素を埋める
コピー&ペーストしても構いません。特に変わったことはありません。
app/routes/home.tsx export default function Home () {
return (
< p id = "index-page" >
これは React Router のデモです。
< br />
< a href = "https://reactrouter.com" >
reactrouter.com のドキュメント
</ a >
をご覧ください。
</ p >
);
}
はい、これで空白スペースはなくなりました。ダッシュボード、統計、フィードなどをインデックスルートに配置するのが一般的です。これらはデータローディングにも参加できます。
Aboutルートの追加
ユーザーが操作できる動的なデータに取り組む前に、めったに変更されない静的なコンテンツを含むページを追加しましょう。Aboutページがこれに最適です。
👉 aboutルートを作成する
touch app/routes/about.tsx
app/routes.ts
にルートを追加することを忘れないでください。
app/routes.ts export default [
index ( "routes/home.tsx" ),
route ( "contacts/:contactId" , "routes/contact.tsx" ),
route ( "about" , "routes/about.tsx" ),
] satisfies RouteConfig ;
👉 aboutページのUIを追加する
特に難しいことはありません。コピー&ペーストしてください。
app/routes/about.tsx import { Link } from "react-router" ;
export default function About () {
return (
< div id = "about" >
< Link to = "/" >← デモへ移動</ Link >
< h1 >React Router Contactsについて</ h1 >
< div >
< p >
これは、動的ルーティング、ネストされたルート、ローダー、アクションなど、React Routerの強力な機能のいくつかを紹介するデモアプリケーションです。
</ p >
< h2 >機能</ h2 >
< p >
React Routerがどのように処理するかをデモで確認してください。
</ p >
< ul >
< li >
ローダーとアクションによるデータのロードと変更
</ li >
< li >
親子関係によるネストされたルーティング
</ li >
< li >動的なセグメントによるURLベースのルーティング</ li >
< li >保留中および楽観的なUI</ li >
</ ul >
< h2 >詳細</ h2 >
< p >
React Routerで優れたWebアプリケーションを構築する方法の詳細については、公式ドキュメント(
< a href = "https://reactrouter.com" >
reactrouter.com
</ a >
)をご覧ください。
</ p >
</ div >
</ div >
);
}
👉 サイドバーにaboutページへのリンクを追加する
app/root.tsx export default function App () {
return (
<>
< div id = "sidebar" >
< h1 >
< Link to = "about" >React Router Contacts</ Link >
</ h1 >
{ /* other elements */ }
</ div >
{ /* other elements */ }
</>
);
}
aboutページ に移動すると、次のようになります。
レイアウトルート
実際には、aboutページをサイドバーレイアウトの中にネストさせたくありません。サイドバーをレイアウトに移動して、aboutページでレンダリングされないようにしましょう。さらに、aboutページですべての連絡先データをロードするのを避けたいと考えています。
👉 サイドバーのレイアウトルートを作成する
このレイアウトルートは好きな場所に名前を付けて配置できますが、layouts
ディレクトリ内に配置すると、シンプルなアプリの整理に役立ちます。
mkdir app/layouts
touch app/layouts/sidebar.tsx
今のところは、<Outlet>
を返すだけです。
app/layouts/sidebar.tsx import { Outlet } from "react-router" ;
export default function SidebarLayout () {
return < Outlet />;
}
👉 ルート定義をサイドバーレイアウトの下に移動する
layout
ルートを定義して、その中のすべてのマッチしたルートに対してサイドバーを自動的にレンダリングできます。これは基本的に以前のroot
と同じですが、特定のルートにスコープを絞ることができます。
app/routes.ts import type { RouteConfig } from "@react-router/dev/routes" ;
import {
index,
layout,
route,
} from "@react-router/dev/routes" ;
export default [
layout ( "layouts/sidebar.tsx" , [
index ( "routes/home.tsx" ),
route ( "contacts/:contactId" , "routes/contact.tsx" ),
]),
route ( "about" , "routes/about.tsx" ),
] satisfies RouteConfig ;
👉 レイアウトとデータフェッチをサイドバーレイアウトに移動する
clientLoader
とApp
コンポーネント内のすべてをサイドバーレイアウトに移動します。次のようになります。
app/layouts/sidebar.tsx import { Form, Link, Outlet } from "react-router" ;
import { getContacts } from "../data" ;
import type { Route } from "./+types/sidebar" ;
export async function clientLoader () {
const contacts = await getContacts ();
return { contacts };
}
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts } = loaderData;
return (
<>
< div id = "sidebar" >
< h1 >
< Link to = "about" >React Router Contacts</ Link >
</ h1 >
< div >
< Form id = "search-form" role = "search" >
< input
aria-label = "Search contacts"
id = "q"
name = "q"
placeholder = "Search"
type = "search"
/>
< div
aria-hidden
hidden ={ true }
id = "search-spinner"
/>
</ Form >
< Form method = "post" >
< button type = "submit" >New</ button >
</ Form >
</ div >
< nav >
{ contacts. length ? (
< ul >
{ contacts. map (( contact ) => (
< li key ={ contact.id } >
< Link to ={ `contacts/${ contact . id }` } >
{ contact.first || contact.last ? (
<>
{ contact.first } { contact.last }
</>
) : (
< i >No Name</ i >
) }
{ contact.favorite ? (
< span >★</ span >
) : null }
</ Link >
</ li >
)) }
</ ul >
) : (
< p >
< i >No contacts</ i >
</ p >
) }
</ nav >
</ div >
< div id = "detail" >
< Outlet />
</ div >
</>
);
}
そして、app/root.tsx
内では、App
は<Outlet>
を返すだけで、未使用のインポートはすべて削除できます。root.tsx
にclientLoader
がないことを確認してください。
app/root.tsx // existing imports and exports
export default function App () {
return < Outlet />;
}
これで、シャッフルが完了し、aboutページは連絡先データをロードしなくなり、サイドバーレイアウトの中にネストされなくなりました。
静的ルートのプリレンダリング
aboutページをリロードすると、クライアントでページがレンダリングされる前に、ほんの一瞬だけローディングスピナーが表示されます。これはあまり良い体験ではありませんし、ページは静的な情報だけなので、ビルド時に静的なHTMLとしてプリレンダリングできるはずです。
👉 aboutページをプリレンダリングする
react-router.config.ts
の中で、React Routerにビルド時に特定のURLをプリレンダリングするように指示するために、設定にprerender
配列を追加できます。この場合、aboutページだけをプリレンダリングしたいとします。
app/react-router.config.ts import { type Config } from "@react-router/dev/config" ;
export default {
ssr: false ,
prerender: [ "/about" ] ,
} satisfies Config ;
これで、aboutページ に移動してリロードしても、ローディングスピナーは表示されません!
リロード時にまだスピナーが表示される場合は、root.tsx
のclientLoader
を削除したことを確認してください。
サーバーサイドレンダリング
React Router は、シングルページアプリケーション を構築するための優れたフレームワークです。多くのアプリケーションはクライアントサイドレンダリングのみで十分に機能し、場合によっては ビルド時にいくつかのページを静的にプリレンダリングするだけで済みます。
もし React Router アプリケーションにサーバーサイドレンダリングを導入したい場合、それは非常に簡単です(以前の ssr: false
ブール値を覚えていますか?)。
👉 サーバーサイドレンダリングを有効にする
app/react-router.config.ts export default {
ssr: true ,
prerender: [ "/about" ] ,
} satisfies Config ;
そして今...何も変わっていない?ページがクライアントでレンダリングされる前に、まだ一瞬だけスピナーが表示されていますか?さらに、clientLoader
を使用しているので、データはまだクライアントでフェッチされているのではないでしょうか?
その通りです!React Router では、必要に応じてクライアントサイドのデータフェッチを行うために clientLoader
(および clientAction
)を依然として使用できます。React Router は、仕事に適したツールを使用するための多くの柔軟性を提供します。
サーバーでデータをフェッチするために使用される loader
の使用に切り替えましょう(ご想像の通り)。
👉 loader
を使用してデータをフェッチするように切り替える
app/layouts/sidebar.tsx // 既存のインポート
export async function loader () {
const contacts = await getContacts ();
return { contacts };
}
ssr
を true
に設定するか false
に設定するかは、あなたとユーザーのニーズによって異なります。どちらの戦略も完全に有効です。このチュートリアルの残りの部分では、サーバーサイドレンダリングを使用しますが、すべてのレンダリング戦略が React Router で第一級市民であることを知っておいてください。
ローダーにおける URL パラメータ
👉 サイドバーのリンクのいずれかをクリックしてください
以前の静的な連絡先ページが再び表示されるはずですが、1つ違いがあります。URL にレコードの実際の ID が含まれるようになりました。
app/routes.ts
のルート定義の :contactId
の部分を覚えていますか?これらの動的なセグメントは、URL のその位置にある動的な(変化する)値と一致します。URL 内のこれらの値を「URL パラメータ」、または略して「パラメータ」と呼びます。
これらの params
は、動的なセグメントと一致するキーを持つローダーに渡されます。たとえば、セグメントの名前は :contactId
なので、値は params.contactId
として渡されます。
これらのパラメータは、ID でレコードを検索するためによく使用されます。試してみましょう。
👉 連絡先ページに loader
関数を追加し、loaderData
でデータにアクセスします
次のコードには型エラーが含まれています。次のセクションで修正します
app/routes/contact.tsx // 既存のインポート
import { getContact } from "../data" ;
import type { Route } from "./+types/contact" ;
export async function loader ({ params } : Route . LoaderArgs ) {
const contact = await getContact (params.contactId);
return { contact };
}
export default function Contact ({
loaderData,
} : Route.ComponentProps) {
const { contact } = loaderData;
// 既存のコード
}
// 既存のコード
レスポンスをスローする
loaderData.contact
の型が ContactRecord | null
であることに気づくでしょう。自動的な型安全性に基づいて、TypeScript はすでに params.contactId
が文字列であることを認識していますが、それが有効な ID であることを確認するための処理は何もしていません。連絡先が存在しない可能性があるため、getContact
は null
を返す可能性があり、それが型エラーの原因となっています。
コンポーネントコードで連絡先が見つからない可能性を考慮することもできますが、Web 的なやり方としては適切な 404 を送信することです。ローダーでそれを行うことで、すべての問題を一度に解決できます。
app/routes/contact.tsx // 既存のインポート
export async function loader ({ params } : Route . LoaderArgs ) {
const contact = await getContact (params.contactId);
if ( ! contact) {
throw new Response ( "Not Found" , { status: 404 });
}
return { contact };
}
// 既存のコード
これで、ユーザーが見つからない場合、このパスでのコード実行は停止し、代わりに React Router がエラーパスをレンダリングします。React Router のコンポーネントは、ハッピーパスのみに集中できます 😁
データミューテーション
すぐに最初の連絡先を作成しますが、その前にHTMLについてお話しましょう。
React Routerは、データミューテーションのプリミティブとしてHTMLフォームのナビゲーションをエミュレートします。これは、JavaScriptのカンブリア爆発以前は唯一の方法でした。そのシンプルさに騙されないでください!React Routerのフォームは、「昔ながらの」Webモデルのシンプルさを持ちながら、クライアントレンダリングアプリのUX機能を提供します。
一部のWeb開発者には馴染みがないかもしれませんが、HTMLのform
は、リンクをクリックするのと同じように、ブラウザでナビゲーションを引き起こします。唯一の違いはリクエストにあります。リンクはURLのみを変更できますが、form
はリクエストメソッド(GET
vs. POST
)とリクエストボディ(POST
フォームデータ)も変更できます。
クライアントサイドルーティングがない場合、ブラウザはform
のデータを自動的にシリアライズし、POST
の場合はリクエストボディとして、GET
の場合はURLSearchParams
としてサーバーに送信します。React Routerも同じことを行いますが、リクエストをサーバーに送信する代わりに、クライアントサイドルーティングを使用し、ルートのaction
関数に送信します。
アプリの「新規」ボタンをクリックして、これを試してみましょう。
React Routerは、このフォームナビゲーションを処理するサーバー側のコードがないため、405を送信します。
コンタクトの作成
ルートルートで action
関数をエクスポートすることで、新しいコンタクトを作成します。ユーザーが「新規」ボタンをクリックすると、フォームはルートルートの action に POST
します。
👉 app/root.tsx
から action
関数をエクスポートします
app/root.tsx // 既存のインポート
import { createEmptyContact } from "./data" ;
export async function action () {
const contact = await createEmptyContact ();
return { contact };
}
// 既存のコード
以上です!「新規」ボタンをクリックすると、新しいレコードがリストに表示されるはずです 🥳
createEmptyContact
メソッドは、名前やデータなどが何もない空のコンタクトを作成するだけです。しかし、それでもレコードは作成されます、約束します!
🧐 ちょっと待って... サイドバーはどうやって更新されたの?action
関数をどこで呼び出したの?データを再取得するコードはどこにあるの?useState
、onSubmit
、useEffect
はどこ?
ここで「昔ながらのウェブ」プログラミングモデルが登場します。<Form>
は、ブラウザがサーバーにリクエストを送信するのを防ぎ、代わりに fetch
を使用してルートの action
関数に送信します。
ウェブのセマンティクスでは、POST
は通常、何らかのデータが変更されていることを意味します。慣例により、React Router はこれをヒントとして使用し、action
が完了した後、ページ上のデータを自動的に再検証します。
実際、すべてが HTML と HTTP であるため、JavaScript を無効にしても、すべてが機能します。React Router がフォームをシリアライズしてサーバーに fetch
リクエストを行う代わりに、ブラウザがフォームをシリアライズしてドキュメントリクエストを行います。そこから React Router はページをサーバー側でレンダリングして送信します。どちらの場合でも、最終的には同じ UI になります。
ただし、JavaScript は残しておきます。なぜなら、回転するファビコンや静的なドキュメントよりも優れたユーザーエクスペリエンスを実現するつもりだからです。
データの更新
新しいレコードの情報を入力する方法を追加しましょう。
データの作成と同様に、<Form>
を使用してデータを更新します。app/routes/edit-contact.tsx
内に新しいルートモジュールを作成しましょう。
👉 連絡先編集ルートを作成する
touch app/routes/edit-contact.tsx
app/routes.ts
にルートを追加することを忘れないでください。
app/routes.ts export default [
layout ( "layouts/sidebar.tsx" , [
index ( "routes/home.tsx" ),
route ( "contacts/:contactId" , "routes/contact.tsx" ),
route (
"contacts/:contactId/edit" ,
"routes/edit-contact.tsx"
),
]),
route ( "about" , "routes/about.tsx" ),
] satisfies RouteConfig ;
👉 編集ページの UI を追加する
これまで見てきたものと変わりません。自由にコピー/ペーストしてください。
app/routes/edit-contact.tsx import { Form } from "react-router" ;
import type { Route } from "./+types/edit-contact" ;
import { getContact } from "../data" ;
export async function loader ({ params } : Route . LoaderArgs ) {
const contact = await getContact (params.contactId);
if ( ! contact) {
throw new Response ( "Not Found" , { status: 404 });
}
return { contact };
}
export default function EditContact ({
loaderData,
} : Route.ComponentProps) {
const { contact } = loaderData;
return (
< Form key ={ contact.id } id = "contact-form" method = "post" >
< p >
< span >名前</ span >
< input
aria-label = "名"
defaultValue ={ contact.first }
name = "first"
placeholder = "名"
type = "text"
/>
< input
aria-label = "姓"
defaultValue ={ contact.last }
name = "last"
placeholder = "姓"
type = "text"
/>
</ p >
< label >
< span >Twitter</ span >
< input
defaultValue ={ contact.twitter }
name = "twitter"
placeholder = "@jack"
type = "text"
/>
</ label >
< label >
< span >アバター URL</ span >
< input
aria-label = "アバター URL"
defaultValue ={ contact.avatar }
name = "avatar"
placeholder = "https://example.com/avatar.jpg"
type = "text"
/>
</ label >
< label >
< span >メモ</ span >
< textarea
defaultValue ={ contact.notes }
name = "notes"
rows ={ 6 }
/>
</ label >
< p >
< button type = "submit" >保存</ button >
< button type = "button" >キャンセル</ button >
</ p >
</ Form >
);
}
新しいレコードをクリックし、「編集」ボタンをクリックしてください。新しいルートが表示されるはずです。
先ほど作成した編集ルートは、すでに form
をレンダリングしています。必要なのは action
関数を追加することだけです。React Router は form
をシリアライズし、fetch
で POST
し、すべてのデータを自動的に再検証します。
👉 編集ルートに action
関数を追加する
app/routes/edit-contact.tsx import { Form, redirect } from "react-router" ;
// 既存のインポート
import { getContact, updateContact } from "../data" ;
export async function action ({
params ,
request ,
} : Route . ActionArgs ) {
const formData = await request. formData ();
const updates = Object. fromEntries (formData);
await updateContact (params.contactId, updates);
return redirect ( `/contacts/${ params . contactId }` );
}
// 既存のコード
フォームに入力して保存を押すと、このような表示になるはずです! (スイカを切る忍耐力があれば、もっと見やすくなるかもしれません。)
ミューテーションに関する議論
😑 うまく動いたけど、何が起こっているのか全くわからない...
もう少し詳しく見ていきましょう...
app/routes/edit-contact.tsx
を開き、form
要素を見てください。それぞれに名前が付いていることに注目してください。
app/routes/edit-contact.tsx < input
aria-label = "First name"
defaultValue ={ contact.first }
name = "first"
placeholder = "First"
type = "text"
/>
JavaScript がない場合、フォームが送信されると、ブラウザは FormData
を作成し、サーバーに送信する際にリクエストのボディとして設定します。前述したように、React Router はそれを防ぎ、代わりに fetch
を使用してリクエストを action
関数に送信することでブラウザをエミュレートします。その際、FormData
も含めます。
form
の各フィールドには formData.get(name)
でアクセスできます。たとえば、上記の入力フィールドの場合、次のようにして姓と名にアクセスできます。
app/routes/edit-contact.tsx export const action = async ({
params,
request,
} : ActionFunctionArgs ) => {
const formData = await request. formData ();
const firstName = formData. get ( "first" );
const lastName = formData. get ( "last" );
// ...
};
フォームフィールドがいくつかあるため、Object.fromEntries
を使用してそれらをすべてオブジェクトに収集しました。これはまさに updateContact
関数が求めているものです。
app/routes/edit-contact.tsx const updates = Object. fromEntries (formData);
updates.first; // "Some"
updates.last; // "Name"
action
関数を除いて、ここで議論している API はどれも React Router によって提供されているものではありません。request
、request.formData
、Object.fromEntries
はすべて Web プラットフォームによって提供されています。
action
を完了した後、最後に redirect
があることに注目してください。
app/routes/edit-contact.tsx export async function action ({
params ,
request ,
} : Route . ActionArgs ) {
invariant (params.contactId, "Missing contactId param" );
const formData = await request. formData ();
const updates = Object. fromEntries (formData);
await updateContact (params.contactId, updates);
return redirect ( `/contacts/${ params . contactId }` );
}
action
関数と loader
関数はどちらも Response
を返すことができます(Request
を受け取っているので当然です!)。redirect
ヘルパーは、アプリに場所を変更するように指示する Response
を返すのを簡単にするだけです。
クライアントサイドルーティングがない場合、サーバーが POST
リクエスト後にリダイレクトすると、新しいページは最新のデータをフェッチしてレンダリングします。以前に学習したように、React Router はこのモデルをエミュレートし、action
呼び出し後にページ上のデータを自動的に再検証します。そのため、フォームを保存するとサイドバーが自動的に更新されます。クライアントサイドルーティングがない場合、追加の再検証コードは存在しないため、React Router でクライアントサイドルーティングを使用する場合も存在する必要はありません。
最後に一つ。JavaScript がない場合、redirect
は通常のリダイレクトになります。ただし、JavaScript がある場合はクライアントサイドのリダイレクトになるため、ユーザーはスクロール位置やコンポーネントの状態などのクライアントの状態を失うことはありません。
新規レコードを編集ページにリダイレクトする
リダイレクトの方法がわかったので、新規連絡先を作成するアクションを更新して、編集ページにリダイレクトするようにしましょう。
👉 新規レコードの編集ページにリダイレクトする
app/root.tsx import {
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
redirect,
} from "react-router" ;
// 既存のインポート
export async function action () {
const contact = await createEmptyContact ();
return redirect ( `/contacts/${ contact . id }/edit` );
}
// 既存のコード
これで「新規」をクリックすると、編集ページに移動するはずです。
アクティブなリンクのスタイリング
レコードがたくさんある今、サイドバーでどのレコードを見ているのかが明確ではありません。NavLink
を使用してこれを修正できます。
👉 サイドバーの <Link>
を <NavLink>
に置き換えてください
app/layouts/sidebar.tsx import { Form, Link, NavLink, Outlet } from "react-router" ;
// 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts } = loaderData;
return (
<>
< div id = "sidebar" >
{ /* 既存の要素 */ }
< ul >
{ contacts. map (( contact ) => (
< li key ={ contact.id } >
< NavLink
className ={ ({ isActive , isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
to ={ `contacts/${ contact . id }` }
>
{ /* 既存の要素 */ }
</ NavLink >
</ li >
)) }
</ ul >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</>
);
}
className
に関数を渡していることに注意してください。ユーザーが <NavLink to>
に一致する URL にいる場合、isActive
は true になります。アクティブになろうとしている(データがまだ読み込まれている)場合は、isPending
が true になります。これにより、ユーザーがどこにいるかを簡単に示すことができ、リンクがクリックされたがデータの読み込みが必要な場合に即座にフィードバックを提供できます。
グローバルな保留中UI
ユーザーがアプリ内を移動する際、React Routerは次のページのデータが読み込まれている間、古いページを表示したまま にします。リスト間をクリックすると、アプリが少し反応しないように感じたかもしれません。アプリが反応しないように感じさせないために、ユーザーに何らかのフィードバックを提供しましょう。
React Routerは、舞台裏で全ての状態を管理し、動的なWebアプリを構築するために必要な要素を明らかにします。この場合、useNavigation
フックを使用します。
👉 useNavigation
を使用してグローバルな保留中UIを追加する
app/layouts/sidebar.tsx import {
Form,
Link,
NavLink,
Outlet,
useNavigation,
} from "react-router" ;
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts } = loaderData;
const navigation = useNavigation ();
return (
<>
{ /* 既存の要素 */ }
< div
className ={
navigation.state === "loading" ? "loading" : ""
}
id = "detail"
>
< Outlet />
</ div >
</>
);
}
useNavigation
は、現在のナビゲーション状態を返します。これは、"idle"
、"loading"
、または"submitting"
のいずれかになります。
この例では、アイドル状態でない場合に、アプリのメイン部分に"loading"
クラスを追加します。CSSは、短い遅延の後(高速な読み込みでUIがちらつくのを避けるため)に、素敵なフェードを追加します。ただし、上部にスピナーやローディングバーを表示するなど、好きなように変更できます。
レコードの削除
連絡先ルートのコードを確認すると、削除ボタンは次のようになっていることがわかります。
app/routes/contact.tsx < Form
action = "destroy"
method = "post"
onSubmit ={ ( event ) => {
const response = confirm (
"このレコードを削除してもよろしいですか?"
);
if ( ! response) {
event. preventDefault ();
}
} }
>
< button type = "submit" >削除</ button >
</ Form >
action
が "destroy"
を指していることに注目してください。<Link to>
と同様に、<Form action>
は 相対的な 値を取ることができます。フォームが contacts/:contactId
ルートでレンダリングされているため、destroy
を持つ相対的なアクションは、クリック時にフォームを contacts/:contactId/destroy
に送信します。
この時点で、削除ボタンを機能させるために必要なことはすべて理解しているはずです。先に進む前に試してみませんか?必要なものは次のとおりです。
新しいルート
そのルートでの action
app/data.ts
からの deleteContact
その後のリダイレクト先
👉 "destroy" ルートモジュールを設定する
touch app/routes/destroy-contact.tsx
app/routes.ts export default [
// 既存のルート
route (
"contacts/:contactId/destroy" ,
"routes/destroy-contact.tsx"
),
// 既存のルート
] satisfies RouteConfig ;
👉 destroy アクションを追加する
app/routes/destroy-contact.tsx import { redirect } from "react-router" ;
import type { Route } from "./+types/destroy-contact" ;
import { deleteContact } from "../data" ;
export async function action ({ params } : Route . ActionArgs ) {
await deleteContact (params.contactId);
return redirect ( "/" );
}
さて、レコードに移動して「削除」ボタンをクリックしてください。動作します!
😅 なぜこれがすべて機能するのか、まだ混乱しています
ユーザーが送信ボタンをクリックすると:
<Form>
は、新しいドキュメント POST
リクエストをサーバーに送信するというデフォルトのブラウザの動作を防ぎますが、代わりにクライアント側のルーティングと fetch
を使用してブラウザをエミュレートすることにより、POST
リクエストを作成します。
<Form action="destroy">
は contacts/:contactId/destroy
の新しいルートと一致し、リクエストを送信します。
action
がリダイレクトした後、React Router はページのデータを取得するためにすべての loader
を呼び出して最新の値を取得します(これが「再検証」です)。routes/contact.tsx
の loaderData
には新しい値が入り、コンポーネントが更新されます!
Form
を追加し、action
を追加すると、残りは React Router が処理します。
キャンセルボタン
編集ページには、まだ何も機能しないキャンセルボタンがあります。これをブラウザの戻るボタンと同じように機能させたいと思います。
ボタンのクリックハンドラーとuseNavigate
が必要です。
👉 useNavigate
を使用してキャンセルボタンのクリックハンドラーを追加する
app/routes/edit-contact.tsx import { Form, redirect, useNavigate } from "react-router" ;
// 既存のインポートとエクスポート
export default function EditContact ({
loaderData,
} : Route.ComponentProps) {
const { contact } = loaderData;
const navigate = useNavigate ();
return (
< Form key ={ contact.id } id = "contact-form" method = "post" >
{ /* 既存の要素 */ }
< p >
< button type = "submit" >保存</ button >
< button onClick ={ () => navigate ( - 1 ) } type = "button" >
キャンセル
</ button >
</ p >
</ Form >
);
}
これで、ユーザーが「キャンセル」をクリックすると、ブラウザの履歴で1つ前のエントリに戻ります。
🧐 なぜボタンに event.preventDefault()
がないのですか?
<button type="button">
は、一見冗長に見えますが、ボタンがフォームを送信するのを防ぐためのHTMLの方法です。
あと2つの機能で終わりです。ゴールは目前です!
URLSearchParams
と GET
送信
これまでのインタラクティブな UI はすべて、URL を変更するリンクか、データを action
関数に POST する form
のいずれかでした。検索フィールドは、両方の混合であるため興味深いものです。つまり、form
ですが、URL を変更するだけで、データは変更しません。
検索フォームを送信するとどうなるか見てみましょう。
👉 検索フィールドに名前を入力して Enter キーを押してください
ブラウザの URL に、URLSearchParams
としてクエリが含まれるようになったことに注目してください。
http://localhost:5173/?q=ryan
<Form method="post">
ではないため、React Router はブラウザをエミュレートし、リクエストボディの代わりに FormData
を URLSearchParams
にシリアライズします。
loader
関数は、request
から検索パラメータにアクセスできます。これを使用してリストをフィルタリングしてみましょう。
👉 URLSearchParams
がある場合はリストをフィルタリングする
app/layouts/sidebar.tsx // 既存のインポートとエクスポート
export async function loader ({
request ,
} : Route . LoaderArgs ) {
const url = new URL (request.url);
const q = url.searchParams. get ( "q" );
const contacts = await getContacts (q);
return { contacts };
}
// 既存のコード
これは POST
ではなく GET
であるため、React Router は action
関数を呼び出しません。GET
form
を送信することは、リンクをクリックすることと同じです。URL のみが変更されます。
これは通常のページナビゲーションでもあることを意味します。戻るボタンをクリックして、元の場所に戻ることができます。
URLとフォームの状態の同期
ここでは、すぐに対応できるUX上の問題がいくつかあります。
検索後に「戻る」をクリックすると、リストがフィルタリングされなくなったにもかかわらず、フォームフィールドには入力した値が残っています。
検索後にページをリロードすると、リストはフィルタリングされているにもかかわらず、フォームフィールドには値がなくなっています。
言い換えれば、URLと入力の状態が同期していないのです。
まず(2)を解決し、URLの値で入力を開始しましょう。
👉 loader
からq
を返し、それを入力のデフォルト値として設定します
app/layouts/sidebar.tsx // 既存のインポートとエクスポート
export async function loader ({
request ,
} : Route . LoaderArgs ) {
const url = new URL (request.url);
const q = url.searchParams. get ( "q" );
const contacts = await getContacts (q);
return { contacts, q };
}
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts , q } = loaderData;
const navigation = useNavigation ();
return (
<>
< div id = "sidebar" >
{ /* 既存の要素 */ }
< div >
< Form id = "search-form" role = "search" >
< input
aria-label = "連絡先を検索"
defaultValue ={ q || "" }
id = "q"
name = "q"
placeholder = "検索"
type = "search"
/>
{ /* 既存の要素 */ }
</ Form >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</>
);
}
これで、検索後にページをリロードすると、入力フィールドにクエリが表示されるようになります。
次に、(1)の問題、つまり「戻る」ボタンをクリックして入力を更新する問題です。ReactからuseEffect
を導入して、DOM内の入力値を直接操作することができます。
👉 入力値をURLSearchParams
と同期させます
app/layouts/sidebar.tsx // 既存のインポート
import { useEffect } from "react" ;
// 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts , q } = loaderData;
const navigation = useNavigation ();
useEffect (() => {
const searchField = document. getElementById ( "q" );
if (searchField instanceof HTMLInputElement ) {
searchField.value = q || "" ;
}
}, [q]);
// 既存のコード
}
🤔 このために制御されたコンポーネントとReact Stateを使うべきではないでしょうか?
確かに、これを制御されたコンポーネントとして行うこともできます。同期ポイントが増えますが、それはあなた次第です。
展開して、どのようなものかを確認してください
app/layouts/sidebar.tsx // 既存のインポート
import { useEffect, useState } from "react" ;
// 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts , q } = loaderData;
const navigation = useNavigation ();
// クエリは状態に保持する必要がある
const [ query , setQuery ] = useState (q || "" );
// クエリを同期させるための`useEffect`はまだある
// 「戻る/進む」ボタンのクリック時にコンポーネントの状態に同期させる
useEffect (() => {
setQuery (q || "" );
}, [q]);
return (
<>
< div id = "sidebar" >
{ /* 既存の要素 */ }
< div >
< Form id = "search-form" role = "search" >
< input
aria-label = "連絡先を検索"
id = "q"
name = "q"
// ユーザーの入力をコンポーネントの状態に同期させる
onChange ={ ( event ) =>
setQuery (event.currentTarget.value)
}
placeholder = "検索"
type = "search"
// `defaultValue`から`value`に切り替え
value ={ query }
/>
{ /* 既存の要素 */ }
</ Form >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</>
);
}
これで、「戻る/進む/リロード」ボタンをクリックすると、入力値がURLと結果と同期するはずです。
ここで製品に関する意思決定を行う必要があります。ユーザーに form
を送信させて結果をフィルタリングしたい場合もあれば、ユーザーが入力するにつれてフィルタリングしたい場合もあります。最初のケースはすでに実装済みなので、2番目のケースがどのようなものか見てみましょう。
useNavigate
はすでに見てきたので、その仲間である useSubmit
を使用します。
app/layouts/sidebar.tsx import {
Form,
Link,
NavLink,
Outlet,
useNavigation,
useSubmit,
} from "react-router" ;
// 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts , q } = loaderData;
const navigation = useNavigation ();
const submit = useSubmit ();
// 既存のコード
return (
<>
< div id = "sidebar" >
{ /* 既存の要素 */ }
< div >
< Form
id = "search-form"
onChange ={ ( event ) =>
submit (event.currentTarget)
}
role = "search"
>
{ /* 既存の要素 */ }
</ Form >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</>
);
}
入力すると、form
が自動的に送信されるようになりました。
submit
への引数に注目してください。submit
関数は、渡されたフォームをシリアライズして送信します。ここでは event.currentTarget
を渡しています。currentTarget
は、イベントがアタッチされている DOM ノード(form
)です。
検索スピナーの追加
本番アプリでは、この検索は、一度にすべてを送信してクライアント側でフィルタリングするには大きすぎるデータベース内のレコードを検索する可能性が高くなります。そのため、このデモにはフェイクのネットワーク遅延が含まれています。
ローディングインジケーターがないと、検索が少し遅く感じられます。データベースを高速化できたとしても、常にユーザーのネットワーク遅延が邪魔になり、制御できません。
より良いユーザーエクスペリエンスのために、検索に対する即時のUIフィードバックを追加しましょう。ここでもuseNavigation
を使用します。
👉 検索中かどうかを判断する変数を追加します
app/layouts/sidebar.tsx // 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
const { contacts , q } = loaderData;
const navigation = useNavigation ();
const submit = useSubmit ();
const searching =
navigation.location &&
new URLSearchParams (navigation.location.search). has (
"q"
);
// 既存のコード
}
何も起こっていない場合、navigation.location
はundefined
になりますが、ユーザーがナビゲートすると、データがロードされている間、次のロケーションが設定されます。次に、location.search
で検索しているかどうかを確認します。
👉 新しいsearching
状態を使用して、検索フォーム要素にクラスを追加します
app/layouts/sidebar.tsx // 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
// 既存のコード
return (
<>
< div id = "sidebar" >
{ /* 既存の要素 */ }
< div >
< Form
id = "search-form"
onChange ={ ( event ) =>
submit (event.currentTarget)
}
role = "search"
>
< input
aria-label = "連絡先を検索"
className ={ searching ? "loading" : "" }
defaultValue ={ q || "" }
id = "q"
name = "q"
placeholder = "検索"
type = "search"
/>
< div
aria-hidden
hidden ={! searching }
id = "search-spinner"
/>
</ Form >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</>
);
}
ボーナスポイントとして、検索時にメイン画面がフェードアウトしないようにします。
app/layouts/sidebar.tsx // 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
// 既存のコード
return (
<>
{ /* 既存の要素 */ }
< div
className ={
navigation.state === "loading" && ! searching
? "loading"
: ""
}
id = "detail"
>
< Outlet />
</ div >
{ /* 既存の要素 */ }
</>
);
}
これで、検索入力の左側に素敵なスピナーが表示されるはずです。
履歴スタックの管理
フォームはキー入力ごとに送信されるため、「alex」と入力してバックスペースで削除すると、巨大な履歴スタックができてしまいます 😂。これは絶対に避けたいです。
これを避けるには、履歴スタックにプッシュするのではなく、次のページで現在のエントリを置き換える ことで対応できます。
👉 submit
で replace
を使用する
app/layouts/sidebar.tsx // 既存のインポートとエクスポート
export default function SidebarLayout ({
loaderData,
} : Route.ComponentProps) {
// 既存のコード
return (
<>
< div id = "sidebar" >
{ /* 既存の要素 */ }
< div >
< Form
id = "search-form"
onChange ={ ( event ) => {
const isFirstSearch = q === null ;
submit (event.currentTarget, {
replace: ! isFirstSearch,
});
} }
role = "search"
>
{ /* 既存の要素 */ }
</ Form >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</ div >
{ /* 既存の要素 */ }
</>
);
}
これが最初の検索かどうかを簡単に確認した後、置き換えるかどうかを決定します。これで、最初の検索は新しいエントリを追加しますが、それ以降のすべてのキー入力は現在のエントリを置き換えます。検索を削除するために7回戻るボタンをクリックする代わりに、ユーザーは1回だけ戻るボタンをクリックすればよくなります。
これまでのところ、すべてのフォームはURLを変更していました。これらのユーザーフローは一般的ですが、ナビゲーションを引き起こさずにフォームを送信したい場合も同様に一般的です。
このような場合のために、useFetcher
があります。これにより、ナビゲーションを引き起こすことなく、action
とloader
と通信できます。
連絡先ページの★ボタンはこれに適しています。新しいレコードを作成または削除するわけではなく、ページを変更したくもありません。単に表示しているページのデータを変更したいだけです。
👉 <Favorite>
フォームをフェッチャーフォームに変更する
app/routes/contact.tsx import { Form, useFetcher } from "react-router" ;
// 既存のインポートとエクスポート
function Favorite ({
contact ,
} : {
contact : Pick < ContactRecord , "favorite" >;
}) {
const fetcher = useFetcher ();
const favorite = contact.favorite;
return (
< fetcher.Form method = "post" >
< button
aria-label ={
favorite
? "お気に入りから削除"
: "お気に入りに追加"
}
name = "favorite"
value ={ favorite ? "false" : "true" }
>
{ favorite ? "★" : "☆" }
</ button >
</ fetcher.Form >
);
}
このフォームはナビゲーションを引き起こさなくなり、単にaction
をフェッチするだけになります。ところで...これはaction
を作成するまで機能しません。
👉 action
を作成する
app/routes/contact.tsx // 既存のインポート
import { getContact, updateContact } from "../data" ;
// 既存のインポート
export async function action ({
params ,
request ,
} : Route . ActionArgs ) {
const formData = await request. formData ();
return updateContact (params.contactId, {
favorite: formData. get ( "favorite" ) === "true" ,
});
}
// 既存のコード
さて、ユーザー名の横にある星をクリックする準備ができました!
見てください、両方の星が自動的に更新されます。新しい<fetcher.Form method="post">
は、これまで使用してきた<Form>
とほぼ同じように機能します。アクションを呼び出し、すべてのデータが自動的に再検証されます。エラーも同じようにキャッチされます。
ただし、1つ重要な違いがあります。ナビゲーションではないため、URLは変更されず、履歴スタックも影響を受けません。
オプティミスティック UI
前のセクションで、お気に入りボタンをクリックしたときにアプリの反応が少し鈍く感じたかもしれません。現実世界で発生するであろうネットワーク遅延を再び追加しました。
ユーザーに何らかのフィードバックを与えるために、以前の navigation.state
とよく似た fetcher.state
を使って、星をローディング状態にすることができますが、今回はさらに良い方法があります。「オプティミスティック UI」と呼ばれる戦略を使用できます。
fetcher は action
に送信される FormData
を認識しているため、fetcher.formData
で利用できます。これを使用して、ネットワークが完了していなくても、星の状態をすぐに更新します。更新が最終的に失敗した場合、UI は実際のデータに戻ります。
👉 fetcher.formData
からオプティミスティックな値を読み取る
app/routes/contact.tsx // 既存のコード
function Favorite ({
contact ,
} : {
contact : Pick < ContactRecord , "favorite" >;
}) {
const fetcher = useFetcher ();
const favorite = fetcher.formData
? fetcher.formData. get ( "favorite" ) === "true"
: contact.favorite;
return (
< fetcher.Form method = "post" >
< button
aria-label ={
favorite
? "お気に入りから削除"
: "お気に入りに追加"
}
name = "favorite"
value ={ favorite ? "false" : "true" }
>
{ favorite ? "★" : "☆" }
</ button >
</ fetcher.Form >
);
}
これで、星をクリックすると、すぐに 新しい状態に変わります。
以上です!React Router を試していただきありがとうございます。このチュートリアルが、優れたユーザーエクスペリエンスを構築するための確かなスタートとなることを願っています。他にもできることはたくさんあるので、必ずすべての API を確認してください 😀