アドレス帳

ここでは、連絡先を管理できる、小さくても機能豊富なアドレス帳アプリを構築します。データベースやその他の「本番環境対応」のものは使用しないため、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;

👉 レイアウトとデータフェッチをサイドバーレイアウトに移動する

clientLoaderAppコンポーネント内のすべてをサイドバーレイアウトに移動します。次のようになります。

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.tsxclientLoaderがないことを確認してください。

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.tsxclientLoaderを削除したことを確認してください。

サーバーサイドレンダリング

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 };
}

ssrtrue に設定するか 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 であることを確認するための処理は何もしていません。連絡先が存在しない可能性があるため、getContactnull を返す可能性があり、それが型エラーの原因となっています。

コンポーネントコードで連絡先が見つからない可能性を考慮することもできますが、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 関数をどこで呼び出したの?データを再取得するコードはどこにあるの?useStateonSubmituseEffect はどこ?

ここで「昔ながらのウェブ」プログラミングモデルが登場します。<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>
  );
}

新しいレコードをクリックし、「編集」ボタンをクリックしてください。新しいルートが表示されるはずです。

FormData を使った連絡先の更新

先ほど作成した編集ルートは、すでに form をレンダリングしています。必要なのは action 関数を追加することだけです。React Router は form をシリアライズし、fetchPOST し、すべてのデータを自動的に再検証します。

👉 編集ルートに 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 によって提供されているものではありません。requestrequest.formDataObject.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 に送信します。

この時点で、削除ボタンを機能させるために必要なことはすべて理解しているはずです。先に進む前に試してみませんか?必要なものは次のとおりです。

  1. 新しいルート
  2. そのルートでの action
  3. app/data.ts からの deleteContact
  4. その後のリダイレクト先

👉 "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("/");
}

さて、レコードに移動して「削除」ボタンをクリックしてください。動作します!

😅 なぜこれがすべて機能するのか、まだ混乱しています

ユーザーが送信ボタンをクリックすると:

  1. <Form> は、新しいドキュメント POST リクエストをサーバーに送信するというデフォルトのブラウザの動作を防ぎますが、代わりにクライアント側のルーティングと fetch を使用してブラウザをエミュレートすることにより、POST リクエストを作成します。
  2. <Form action="destroy">contacts/:contactId/destroy の新しいルートと一致し、リクエストを送信します。
  3. action がリダイレクトした後、React Router はページのデータを取得するためにすべての loader を呼び出して最新の値を取得します(これが「再検証」です)。routes/contact.tsxloaderData には新しい値が入り、コンポーネントが更新されます!

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つの機能で終わりです。ゴールは目前です!

URLSearchParamsGET 送信

これまでのインタラクティブな UI はすべて、URL を変更するリンクか、データを action 関数に POST する form のいずれかでした。検索フィールドは、両方の混合であるため興味深いものです。つまり、form ですが、URL を変更するだけで、データは変更しません。

検索フォームを送信するとどうなるか見てみましょう。

👉 検索フィールドに名前を入力して Enter キーを押してください

ブラウザの URL に、URLSearchParams としてクエリが含まれるようになったことに注目してください。

http://localhost:5173/?q=ryan

<Form method="post"> ではないため、React Router はブラウザをエミュレートし、リクエストボディの代わりに FormDataURLSearchParams にシリアライズします。

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上の問題がいくつかあります。

  1. 検索後に「戻る」をクリックすると、リストがフィルタリングされなくなったにもかかわらず、フォームフィールドには入力した値が残っています。
  2. 検索後にページをリロードすると、リストはフィルタリングされているにもかかわらず、フォームフィールドには値がなくなっています。

言い換えれば、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と結果と同期するはずです。

FormonChange を送信する

ここで製品に関する意思決定を行う必要があります。ユーザーに 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.locationundefinedになりますが、ユーザーがナビゲートすると、データがロードされている間、次のロケーションが設定されます。次に、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」と入力してバックスペースで削除すると、巨大な履歴スタックができてしまいます 😂。これは絶対に避けたいです。

これを避けるには、履歴スタックにプッシュするのではなく、次のページで現在のエントリを置き換えることで対応できます。

👉 submitreplace を使用する

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回だけ戻るボタンをクリックすればよくなります。

ナビゲーションなしのForm

これまでのところ、すべてのフォームはURLを変更していました。これらのユーザーフローは一般的ですが、ナビゲーションを引き起こさずにフォームを送信したい場合も同様に一般的です。

このような場合のために、useFetcherがあります。これにより、ナビゲーションを引き起こすことなく、actionloaderと通信できます。

連絡先ページの★ボタンはこれに適しています。新しいレコードを作成または削除するわけではなく、ページを変更したくもありません。単に表示しているページのデータを変更したいだけです。

👉 <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 を確認してください 😀