Remix チュートリアル

連絡先を管理できる、小さくても機能豊富なアプリを作成します。データベースやその他の「本番環境対応」のものは使用しないため、Remix に集中できます。一緒に作業すれば約30分かかりますが、そうでなければ簡単に読めます。

👉 これが表示されたら、アプリで何か操作する必要があります!

残りは情報とより深い理解のためです。始めましょう。

セットアップ

👉 基本テンプレートの生成

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

これは非常にシンプルなテンプレートを使用しますが、CSSとデータモデルが含まれているため、Remixに集中できます。より詳しく知りたい場合は、クイックスタートでRemixプロジェクトの基本的なセットアップについて学ぶことができます。

👉 アプリの起動

# アプリディレクトリに移動
cd {アプリを配置した場所}
 
# まだインストールしていない場合は依存関係をインストール
npm install
 
# サーバーを起動
npm run dev

http://localhost:5173 を開くと、次のようなスタイルのない画面が表示されます。

ルートルート

app/root.tsx のファイルに注目してください。これは「ルートルート」と呼んでいます。UIで最初にレンダリングされるコンポーネントなので、通常はページのグローバルレイアウトが含まれています。

ルートコンポーネントのコードを見るにはここをクリック
app/root.tsx
import {
  Form,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
 
export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix 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>
 
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Remix アプリをスタイリングする方法は複数ありますが、Remix に焦点を当てるために、既に記述済みのプレーンなスタイルシートを使用します。

CSS ファイルは JavaScript モジュールに直接インポートできます。Vite はアセットのフィンガープリントを作成し、ビルドのクライアントディレクトリに保存し、公開アクセス可能な href をモジュールに提供します。

👉 アプリスタイルのインポート

app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// 既存のインポート
 
import appStylesHref from "./app.css?url";
 
export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

すべてのルートは links 関数をエクスポートできます。それらは収集され、app/root.tsx でレンダリングした <Links /> コンポーネントにレンダリングされます。

アプリはこれで次のようになります。CSSも書けるデザイナーがいるのは本当に素晴らしいですね!(Jimさん、ありがとうございます 🙏)。

コンタクトルートUI

サイドバーのアイテムをクリックすると、デフォルトの404ページが表示されます。/contacts/1というURLに一致するルートを作成しましょう。

👉 app/routesディレクトリとコンタクトルートモジュールの作成

mkdir app/routes
touch app/routes/contacts.\$contactId.tsx

Remixのルートファイルの命名規則では、.はURLに/を作成し、$はセグメントを動的にします。これにより、次のようなURLに一致するルートが作成されました。

  • /contacts/123
  • /contacts/abc

👉 コンタクトコンポーネントUIの追加

いくつかの要素だけです。コピー&ペーストして構いません。

app/routes/contacts.$contactId.tsx
import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";
 
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>
  );
}
 
const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  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にアクセスしても、何も変わりませんか?

contact route with blank main content

ネストされたルートとアウトレット

RemixはReact Router上に構築されているため、ネストされたルーティングをサポートしています。子ルートを親レイアウト内でレンダリングするには、親にOutletをレンダリングする必要があります。これを修正するために、app/root.tsxを開いて、アウトレットをレンダリングしましょう。

👉 <Outlet />をレンダリングする

app/root.tsx
// 既存のインポート
import {
  Form,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
 
// 既存のインポートとコード
 
export default function App() {
  return (
    <html lang="en">
      {/* その他の要素 */}
      <body>
        <div id="sidebar">{/* その他の要素 */}</div>
        <div id="detail">
          <Outlet />
        </div>
        {/* その他の要素 */}
      </body>
    </html>
  );
}

これで、子ルートがアウトレットを通してレンダリングされるはずです。

メインコンテンツを含む連絡先ルート

クライアントサイドルーティング

気づかれたかもしれませんが、サイドバーのリンクをクリックすると、ブラウザはクライアントサイドルーティングではなく、次のURLに対して完全なドキュメントリクエストを行っています。

クライアントサイドルーティングにより、アプリはサーバーから別のドキュメントをリクエストすることなく、URLを更新できます。代わりに、アプリはすぐに新しいUIをレンダリングできます。<Link>を使って実現しましょう。

👉 サイドバーの<a href><Link to>に変更してください

app/root.tsx
// 既存のインポート
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";
 
// 既存のインポートとエクスポート
 
export default function App() {
  return (
    <html lang="en">
      {/* その他の要素 */}
      <body>
        <div id="sidebar">
          {/* その他の要素 */}
          <nav>
            <ul>
              <li>
                <Link to={`/contacts/1`}>Your Name</Link>
              </li>
              <li>
                <Link to={`/contacts/2`}>Your Friend</Link>
              </li>
            </ul>
          </nav>
        </div>
        {/* その他の要素 */}
      </body>
    </html>
  );
}

ブラウザの開発ツールでネットワークタブを開くと、ドキュメントをリクエストしなくなっていることが確認できます。

データの読み込み

URL セグメント、レイアウト、データは、ほとんどの場合、互いに関連付けられています(3つ組?)。このアプリでも既に確認できます。

URL セグメントコンポーネントデータ
/<Root>連絡先のリスト
contacts/:contactId<Contact>個々の連絡先

この自然な結合のため、Remix には、ルートコンポーネントに簡単にデータを取得するためのデータ規則があります。

データを読み込むために使用する2つのAPI、loaderuseLoaderDataがあります。 まず、ルートルートにloader関数を生成してエクスポートし、データをレンダリングします。

👉 app/root.tsxからloader関数をエクスポートしてデータをレンダリングする

次のコードには型エラーが含まれています。これは次のセクションで修正します。

app/root.tsx
// 既存のインポート
import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";
 
// 既存のインポート
import { getContacts } from "./data";
 
// 既存のエクスポート
 
export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};
 
export default function App() {
  const { contacts } = useLoaderData();
 
  return (
    <html lang="en">
      {/* その他の要素 */}
      <body>
        <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>
        {/* その他の要素 */}
      </body>
    </html>
  );
}

以上です!Remix はこれで、データをUIと自動的に同期状態に保ちます。サイドバーは次のようになります。

型推論

map 内の contact 型について、TypeScript が警告を出していることに気づかれたかもしれません。typeof loader を使用してデータに関する型推論を行うための簡単なアノテーションを追加できます。

app/root.tsx
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
 
  // 既存のコード
}

ローダーにおけるURLパラメータ

👉 サイドバーのリンクのいずれかをクリックしてください

以前の静的なコンタクトページが表示されるはずです。違いは、URLにレコードの実際のIDが含まれるようになったことです。

app/routes/contacts.$contactId.tsxのファイル名にある$contactIdの部分を覚えていますか?これらの動的なセグメントは、URLのその位置にある動的な(変化する)値と一致します。URL内のこれらの値を「URLパラメータ」、または単に「パラメータ」と呼びます。

これらのparamsは、動的セグメントと一致するキーを使用してローダーに渡されます。たとえば、セグメントの名前が$contactIdなので、値はparams.contactIdとして渡されます。

これらのパラメータは、多くの場合、IDでレコードを見つけるために使用されます。試してみましょう。

👉 コンタクトページにloader関数を追加し、useLoaderDataでデータにアクセスします

次のコードには型エラーが含まれています。次のセクションで修正します。

app/routes/contacts.$contactId.tsx
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
// 既存のインポート
 
import { getContact } from "../data";
 
export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};
 
export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();
 
  // 既存のコード
}
 
// 既存のコード

パラメータの検証とレスポンスの送出

TypeScript が怒っています。TypeScript を満足させ、それがどのような考慮事項を強制するかを見てみましょう。

app/routes/contacts.$contactId.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
// 既存のインポート
import invariant from "tiny-invariant";
 
// 既存のインポート
 
export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  return json({ contact });
};
 
// 既存のコード

まず、ファイル名とコード間でパラメータの名前を間違えた可能性があることがわかります(ファイル名を変更したのかもしれません!)。invariant は、コードの潜在的な問題を予測したときに、カスタムメッセージ付きのエラーをスローするための便利な関数です。

次に、useLoaderData<typeof loader>() は、コンタクトを取得したか、null を取得したか(その ID のコンタクトが存在しない可能性があります)を知るようになりました。この潜在的な null はコンポーネントコードにとって面倒であり、TypeScript のエラーはまだ飛び回っています。

コンポーネントコードでコンタクトが見つからない可能性を考慮することもできますが、Web 的な方法は適切な 404 を送信することです。ローダーでこれを行うことで、すべての問題を一度に解決できます。

app/routes/contacts.$contactId.tsx
// 既存のインポート
 
export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};
 
// 既存のコード

これで、ユーザーが見つからない場合、このパスでのコード実行は停止し、Remix は代わりにエラーパスをレンダリングします。Remix のコンポーネントは、ハッピーパスにのみ集中できます 😁

データの変更

最初のコンタクトをすぐに作成しますが、まずはHTMLについて説明しましょう。

Remixは、データ変更のプリミティブとしてHTMLフォームのナビゲーションをエミュレートします。これは、JavaScriptのカンブリア爆発以前は唯一の方法でした。そのシンプルさに騙されてはいけません!Remixのフォームは、クライアントサイドレンダリングアプリのUX機能を、「旧来型」のウェブモデルのシンプルさで実現します。

一部のWeb開発者には馴染みがありませんが、HTMLのformは、リンクをクリックするのと同じように、ブラウザ内でナビゲーションを引き起こします。唯一の違いはリクエストにあります。リンクはURLのみを変更できますが、formはリクエストメソッド(GETPOST)とリクエストボディ(POSTフォームデータ)も変更できます。

クライアントサイドルーティングがない場合、ブラウザはformのデータを自動的にシリアライズし、POSTのリクエストボディとして、GETの場合はURLSearchParamsとしてサーバーに送信します。Remixも同じことを行いますが、サーバーにリクエストを送信する代わりに、クライアントサイドルーティングを使用して、ルートのaction関数に送信します。

アプリの「新規」ボタンをクリックして、これを試すことができます。

Remixは、このフォームナビゲーションを処理するサーバー側のコードがないため、405エラーを送信します。

url-search-params: URLSearchParamsの説明へのリンク (URLSearchParamsの説明へのリンクをここに挿入してください) action: action関数の説明へのリンク (action関数の説明へのリンクをここに挿入してください)

連絡先の作成

ルートルートからaction関数をエクスポートすることで、新しい連絡先を作成します。ユーザーが「新規」ボタンをクリックすると、フォームはルートルートアクションにPOSTを送信します。

👉 app/root.tsxからaction関数をエクスポートする

app/root.tsx
// 既存のインポート
 
import { createEmptyContact, getContacts } from "./data";
 
export const action = async () => {
  const contact = await createEmptyContact();
  return json({ contact });
};
 
// 既存のコード

以上です!「新規」ボタンをクリックしてみてください。新しいレコードがリストに追加されるはずです🥳

createEmptyContactメソッドは、名前やデータのない空の連絡先を作成するだけです。しかし、レコードは作成されます!

🧐ちょっと待って…サイドバーはどうやって更新されたのですか?action関数はどこで呼び出されたのですか?データを再取得するコードはどこにあるのですか?useStateonSubmituseEffectはどこにあるのですか?!

ここで「旧来のウェブ」プログラミングモデルが登場します。<Form>は、ブラウザがサーバーへのリクエストを送信するのを防ぎ、代わりにfetchを使用してルートのaction関数に送信します。

ウェブセマンティクスでは、POSTは通常、データが変更されていることを意味します。慣例により、Remixはこれをヒントとして使用し、actionが完了した後にページのデータを自動的に再検証します。

実際、すべてがHTMLとHTTPであるため、JavaScriptを無効にしても、すべて正常に動作します。Remixがフォームをシリアル化してサーバーにfetchリクエストを行う代わりに、ブラウザがフォームをシリアル化してドキュメントリクエストを行います。そこからRemixはサーバーサイドでページをレンダリングし、送信します。最終的には同じUIです。

しかし、ファビコンの回転や静的なドキュメントよりも優れたユーザーエクスペリエンスを作成するため、JavaScriptは使用し続けます。

データの更新

新しいレコードの情報を入力する方法を追加しましょう。

データの作成と同様に、<Form> を使用してデータを更新します。app/routes/contacts.$contactId_.edit.tsx に新しいルートを作成しましょう。

👉 編集コンポーネントの作成

touch app/routes/contacts.\$contactId_.edit.tsx

$contactId_ の奇妙な _ に注目してください。デフォルトでは、ルートは同じプレフィックス名のルート内に自動的にネストされます。末尾に _ を追加すると、ルートが app/routes/contacts.$contactId.tsx 内にネストされません。詳細はルートファイルの命名規則ガイドを参照してください。

👉 編集ページUIの追加

これまでに見たことのないものはありません。コピー&ペーストして自由に使用してください。

app/routes/contacts.$contactId_.edit.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
 
import { getContact } from "../data";
 
export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};
 
export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
 
  return (
    <Form key={contact.id} id="contact-form" method="post">
      <p>
        <span>Name</span>
        <input
          aria-label="First name"
          defaultValue={contact.first}
          name="first"
          placeholder="First"
          type="text"
        />
        <input
          aria-label="Last name"
          defaultValue={contact.last}
          name="last"
          placeholder="Last"
          type="text"
        />
      </p>
      <label>
        <span>Twitter</span>
        <input
          defaultValue={contact.twitter}
          name="twitter"
          placeholder="@jack"
          type="text"
        />
      </label>
      <label>
        <span>Avatar URL</span>
        <input
          aria-label="Avatar URL"
          defaultValue={contact.avatar}
          name="avatar"
          placeholder="https://example.com/avatar.jpg"
          type="text"
        />
      </label>
      <label>
        <span>Notes</span>
        <textarea
          defaultValue={contact.notes}
          name="notes"
          rows={6}
        />
      </label>
      <p>
        <button type="submit">Save</button>
        <button type="button">Cancel</button>
      </p>
    </Form>
  );
}

これで、新しいレコードをクリックし、「編集」ボタンをクリックすると、新しいルートが表示されます。

FormDataを使った連絡先の更新

作成したばかりの編集ルートは既にformをレンダリングしています。必要なのはaction関数を追加することだけです。Remixはformをシリアライズし、fetchを使ってPOSTし、自動的にすべてのデータを再検証します。

👉 編集ルートにaction関数を追加する

app/routes/contacts.$contactId_.edit.tsx
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
// 既存のインポート
 
import { getContact, updateContact } from "../data";
 
export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  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}`);
};
 
// 既存のコード

フォームに記入して保存ボタンを押すと、このような表示になります!(ただし、見やすく、そしておそらく毛深い部分は少なくなっています。)

Mutationに関する議論

😑 動いたけど、何が起こっているのか全く分からない…

少し詳しく見ていきましょう…

contacts.$contactId_.edit.tsxを開き、「form」要素を見てください。それぞれにname属性があることに注目してください。

app/routes/contacts.$contactId_.edit.tsx
<input
  aria-label="First name"
  defaultValue={contact.first}
  name="first"
  placeholder="First"
  type="text"
/>

JavaScriptがない場合、フォームが送信されると、ブラウザはFormDataを作成し、それをサーバーに送信する際の要求の本文として設定します。前に述べたように、Remixはそれを防ぎ、代わりにfetchを使用して要求をaction関数に送信することでブラウザをエミュレートし、FormDataを含めます。

form内の各フィールドは、formData.get(name)でアクセスできます。例えば、上記の入力フィールドの場合、名と姓にはこのようにアクセスできます。

app/routes/contacts.$contactId_.edit.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/contacts.$contactId_.edit.tsx
const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"

action関数以外では、ここで説明しているAPIはRemixによって提供されているものではありません。requestrequest.formDataObject.fromEntriesはすべてWebプラットフォームによって提供されています。

action関数が完了した後、最後にあるredirectに注目してください。

app/routes/contacts.$contactId_.edit.tsx
export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  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リクエスト後にサーバーがリダイレクトすると、新しいページは最新のデータを取得してレンダリングします。前に学んだように、Remixはこのモデルをエミュレートし、action呼び出し後にページのデータを自動的に再検証します。そのため、フォームを保存するとサイドバーが自動的に更新されます。クライアントサイドルーティングがないと、追加の再検証コードは存在しないため、Remixではクライアントサイドルーティングがあっても存在する必要はありません!

最後に1点。JavaScriptがない場合、redirectは通常のredirectになります。しかし、JavaScriptを使用すると、クライアントサイドのリダイレクトになるため、スクロール位置やコンポーネントの状態などのクライアントの状態が失われることはありません。

新規レコードを編集ページにリダイレクトする

リダイレクトの方法が分かったところで、新規連絡先を作成するアクションを編集ページにリダイレクトするように更新しましょう。

👉 新規レコードの編集ページにリダイレクトする

app/root.tsx
// 既存のインポート
import { json, redirect } from "@remix-run/node";
// 既存のインポート
 
export const action = async () => {
  const contact = await createEmptyContact();
  return redirect(`/contacts/${contact.id}/edit`);
};
 
// 既存のコード

これで、「新規」をクリックすると、編集ページに移動するはずです。

アクティブリンクのスタイル設定

多くのレコードがあるため、サイドバーで現在見ているレコードが分かりにくくなっています。これを修正するためにNavLinkを使用できます。

👉 サイドバーで<Link><NavLink>に置き換えてください

app/root.tsx
// 既存のインポート
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";
 
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        <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>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

classNameに関数を渡していることに注意してください。ユーザーが<NavLink to>と一致するURLにいる場合、isActivetrueになります。アクティブになる直前(データの読み込み中)の場合、isPendingtrueになります。これにより、ユーザーの現在位置を簡単に示し、リンクをクリックしたときにデータの読み込みが必要な場合にもすぐにフィードバックを提供できます。

グローバルなPending UI

ユーザーがアプリを操作する際、Remixは次のページのデータが読み込まれている間、古いページを表示したままにします。リスト間をクリックすると、アプリが少し反応しなくなっていることに気づいたかもしれません。アプリが反応しないように感じさせないように、ユーザーにフィードバックを提供しましょう。

Remixはバックグラウンドで全ての状態を管理し、動的なウェブアプリを構築するために必要な部分を公開します。この場合、useNavigationフックを使用します。

👉 useNavigation を使用してグローバルなPending UIを追加する

app/root.tsx
// 既存のインポート
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
} from "@remix-run/react";
 
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        {/* 既存の要素 */}
        <div
          className={
            navigation.state === "loading" ? "loading" : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

useNavigation は現在のナビゲーションの状態を返します。これは"idle""loading"、または"submitting"のいずれかになります。

この場合、アイドル状態ではない場合は、アプリの主要部分に"loading"クラスを追加します。その後、CSSによって短い遅延の後、スムーズなフェードが追加されます(高速読み込み時のUIのちらつきを防ぐため)。ただし、スピナーやトップに表示されるローディングバーなど、何でも表示できます。

レコードの削除

contact ルートのコードを確認すると、削除ボタンは次のようになっています。

app/routes/contact.$contactId.tsx
<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>

action"destroy" を指していることに注意してください。<Link to> と同様に、<Form action>相対的な値を取ることができます。フォームは contacts.$contactId.tsx でレンダリングされるため、destroy という相対的なアクションは、クリックされたときにフォームを contacts.$contactId.destroy に送信します。

この時点で、削除ボタンを動作させるために必要なことはすべて知っているはずです。先に進む前に、試してみてはいかがでしょうか?次のものが必要です。

  1. 新しいルート
  2. そのルートのアクション
  3. app/data.ts からの deleteContact
  4. どこかへのリダイレクト

👉 "destroy" ルートモジュールの作成

touch app/routes/contacts.\$contactId_.destroy.tsx

👉 destroy アクションの追加

app/routes/contacts.$contactId_.destroy.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
 
import { deleteContact } from "../data";
 
export const action = async ({
  params,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  await deleteContact(params.contactId);
  return redirect("/");
};

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

😅 まだなぜこれが動作するのかよく分かりません

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

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

Form を追加し、action を追加すると、Remix が残りの処理を行います。

インデックスルート

アプリをロードすると、リストの右側には大きな空白ページが表示されていることに気付くでしょう。

ルートに子ルートがあり、親ルートのパスにいる場合、<Outlet> は一致する子ルートがないため、何もレンダリングしません。インデックスルートは、その空白を埋めるデフォルトの子ルートだと考えることができます。

👉 ルートルートのインデックスルートを作成する

touch app/routes/_index.tsx

👉 インデックスコンポーネントの要素を入力する

コピー&ペーストしていただいて構いません。特別なことはありません。

app/routes/_index.tsx
export default function Index() {
  return (
    <p id="index-page">
      これはRemixのデモです。
      <br />
      <a href="https://remix.run">remix.runのドキュメント</a>をご覧ください。
    </p>
  );
}

ルート名_indexは特別なものです。これはRemixに、ユーザーが親ルートの正確なパスにいる場合、このルートを一致させてレンダリングするように指示します。そのため、<Outlet />でレンダリングする他の子ルートはありません。

できました!空白はもうありません。ダッシュボード、統計、フィードなどをインデックスルートに配置することが一般的です。これらはデータの読み込みにも参加できます。

キャンセルボタン

編集ページには、まだ何も機能していないキャンセルボタンがあります。ブラウザの戻るボタンと同じ動作をするようにしたいです。

ボタンのクリックハンドラとuseNavigateが必要です。

👉 useNavigate を使用してキャンセルボタンのクリックハンドラを追加する

app/routes/contacts.$contactId_.edit.tsx
// 既存のインポート
import {
  Form,
  useLoaderData,
  useNavigate,
} from "@remix-run/react";
// 既存のインポートとエクスポート
 
export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  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関数にデータを送信するformのいずれかでした。検索フィールドは両方の混合で興味深いものです。formですが、データは変更せず、URLのみを変更します。

検索フォームを送信したときに何が起こるか見てみましょう。

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

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

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

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

loader関数は、requestから検索パラメータにアクセスできます。これを利用してリストをフィルタリングしましょう。

👉 URLSearchParamsがあればリストをフィルタリングする

app/root.tsx
import type {
  LinksFunction,
  LoaderFunctionArgs,
} from "@remix-run/node";
 
// 既存のインポートとエクスポート
 
export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts });
};
 
// 既存のコード

これはGETではなくPOSTであるため、Remixはaction関数を呼び出しません。GETformを送信することは、リンクをクリックすることと同じです。URLだけが変更されます。

これは通常のページナビゲーションであることも意味します。戻るボタンをクリックして、元の場所に戻ることができます。

URLとフォーム状態の同期化

いくつかのUX上の問題を迅速に解決しましょう。

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

つまり、URLと入力の状態が同期していません。

まず(2)を解決し、URLの値で入力を開始しましょう。

👉 loaderからqを返し、それを入力のデフォルト値として設定します

app/root.tsx
// 既存のインポートとエクスポート
 
export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts, q });
};
 
export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        <div id="sidebar">
          {/* 既存の要素 */}
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              {/* 既存の要素 */}
            </Form>
            {/* 既存の要素 */}
          </div>
          {/* 既存の要素 */}
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

これで、検索後にページを更新しても、入力フィールドにクエリが表示されるようになります。

次に、問題(1)、戻るボタンをクリックして入力を更新する方法についてです。ReactのuseEffectを使用して、DOM内の入力値を直接操作できます。

👉 URLSearchParamsと入力値を同期化します

app/root.tsx
// 既存のインポート
import { useEffect } from "react";
 
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
 
  useEffect(() => {
    const searchField = document.getElementById("q");
    if (searchField instanceof HTMLInputElement) {
      searchField.value = q || "";
    }
  }, [q]);
 
  // 既存のコード
}

🤔 制御されたコンポーネントとReactの状態を使うべきではないでしょうか?

制御されたコンポーネントとして実装することもできます。同期するポイントが増えますが、どちらの方法でも構いません。

展開して詳細を確認
app/root.tsx
// 既存のインポート
import { useEffect, useState } from "react";
 
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  // クエリは状態として保持する必要があります
  const [query, setQuery] = useState(q || "");
 
  // 戻る/進むボタンをクリックしたときにクエリをコンポーネントの状態に同期させる`useEffect`は残っています
  useEffect(() => {
    setQuery(q || "");
  }, [q]);
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        <div id="sidebar">
          {/* 既存の要素 */}
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                id="q"
                name="q"
                // ユーザーの入力をコンポーネントの状態に同期
                onChange={(event) =>
                  setQuery(event.currentTarget.value)
                }
                placeholder="Search"
                type="search"
                // `defaultValue`から`value`に変更
                value={query}
              />
              {/* 既存の要素 */}
            </Form>
            {/* 既存の要素 */}
          </div>
          {/* 既存の要素 */}
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

これで、戻る/進む/更新ボタンをクリックしても、入力値がURLと結果と同期するようになります。

FormonChangeの送信

ここで製品に関する意思決定を行う必要があります。ユーザーに結果をフィルタリングするためのformを送信させたい場合と、ユーザーが入力する際にフィルタリングさせたい場合があります。前者は既に実装しているので、後者を見てみましょう。

useNavigateは既に見てきましたが、今回はその仲間であるuseSubmitを使用します。

app/root.tsx
// 既存のインポート
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
  useSubmit,
} from "@remix-run/react";
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();
 
  // 既存のコード
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        <div id="sidebar">
          {/* 既存の要素 */}
          <div>
            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              {/* 既存の要素 */}
            </Form>
            {/* 既存の要素 */}
          </div>
          {/* 既存の要素 */}
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

入力すると、formが自動的に送信されるようになりました!

submitへの引数に注目してください。submit関数は、渡された任意のフォームをシリアライズして送信します。ここではevent.currentTargetを渡しています。currentTargetは、イベントがアタッチされているDOMノード(form)です。

use-submit: https://reactrouter.com/docs/en/v6/hooks/use-submit

検索スピナーの追加

本番アプリでは、この検索は、一度にすべてを送信してクライアント側でフィルタリングするには大きすぎるデータベース内のレコードを検索している可能性が高いです。そのため、このデモでは、ネットワーク遅延を偽装しています。

ローディングインジケーターがないと、検索は少し遅く感じられます。データベースを高速化できたとしても、ユーザーのネットワーク遅延は常に存在し、制御できません。

より良いユーザーエクスペリエンスのために、検索に対する即時のUIフィードバックを追加しましょう。useNavigationを再び使用します。

👉 検索中かどうかを知るための変数の追加

app/root.tsx
// 既存のインポートとエクスポート
 
export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );
 
  // 既存のコード
}

何も起こっていない場合、navigation.locationundefinedになりますが、ユーザーがナビゲートすると、データの読み込み中に次の場所に設定されます。その後、location.searchを使用して検索中かどうかを確認します。

👉 新しいsearching状態を使用して検索フォーム要素にクラスを追加

app/root.tsx
// 既存のインポートとエクスポート
 
export default function App() {
  // 既存のコード
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        <div id="sidebar">
          {/* 既存の要素 */}
          <div>
            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              <input
                aria-label="Search contacts"
                className={searching ? "loading" : ""}
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              <div
                aria-hidden
                hidden={!searching}
                id="search-spinner"
              />
            </Form>
            {/* 既存の要素 */}
          </div>
          {/* 既存の要素 */}
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

ボーナスとして、検索時にメイン画面をフェードアウトしないようにします。

app/root.tsx
// 既存のインポートとエクスポート
 
export default function App() {
  // 既存のコード
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        {/* 既存の要素 */}
        <div
          className={
            navigation.state === "loading" && !searching
              ? "loading"
              : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

これで、検索入力の左側に素敵なスピナーが表示されるはずです。

ヒストリスタックの管理

フォームがキーストロークごとに送信されるため、「alex」と入力してからバックスペースで削除すると、膨大なヒストリスタックが生成されます😂。これは明らかに避けなければなりません。

これを回避するには、ヒストリスタックにプッシュする代わりに、現在のエントリを次のページで置き換えることができます。

👉 submitreplace を使用

app/root.tsx
// 既存のインポートとエクスポート
 
export default function App() {
  // 既存のコード
 
  return (
    <html lang="en">
      {/* 既存の要素 */}
      <body>
        <div id="sidebar">
          {/* 既存の要素 */}
          <div>
            <Form
              id="search-form"
              onChange={(event) => {
                const isFirstSearch = q === null;
                submit(event.currentTarget, {
                  replace: !isFirstSearch,
                });
              }}
              role="search"
            >
              {/* 既存の要素 */}
            </Form>
            {/* 既存の要素 */}
          </div>
          {/* 既存の要素 */}
        </div>
        {/* 既存の要素 */}
      </body>
    </html>
  );
}

これが最初の検索かどうかを簡単に確認した後、置き換えるかどうかを決定します。最初の検索では新しいエントリが追加されますが、それ以降のキーストロークでは現在のエントリが置き換えられます。検索を削除するために7回戻るボタンをクリックする代わりに、ユーザーは1回戻るボタンをクリックするだけで済みます。

ナビゲーションなしのForm

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

このような場合、useFetcherを使用します。これにより、ナビゲーションを起こすことなくactionloaderと通信できます。

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

👉 <Favorite>フォームをfetcherフォームに変更する

app/routes/contacts.$contactId.tsx
// 既存のインポート
import {
  Form,
  useFetcher,
  useLoaderData,
} from "@remix-run/react";
// 既存のインポートとエクスポート
 
// 既存のコード
 
const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  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/contacts.$contactId.tsx
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
// 既存のインポート
 
import { getContact, updateContact } from "../data";
// 既存のインポート
 
export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  return updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true",
  });
};
 
// 既存のコード

準備完了です。ユーザー名の横にある星をクリックしましょう!

見てください、両方の星が自動的に更新されます。新しい<fetcher.Form method="post">は、これまで使用してきた<Form>とほぼ同じように機能します。アクションを呼び出し、その後すべてのデータが自動的に再検証されます。エラーも同様にキャッチされます。

ただし、重要な違いが1つあります。ナビゲーションではないため、URLは変更されず、履歴スタックは影響を受けません。

楽観的UI

前のセクションのお気に入りボタンをクリックした際、アプリが少し反応が鈍いと感じたかもしれません。現実世界でも発生する可能性があるため、ネットワーク遅延を追加しました。

ユーザーにフィードバックを与えるために、fetcher.state(以前のnavigation.stateとよく似ています)を使用して、星をローディング状態にすることもできますが、今回はさらに優れた方法があります。「楽観的UI」と呼ばれる戦略を使用できます。

フェッチャーは、actionに送信されるFormDataを知っているので、fetcher.formDataで利用できます。ネットワークが完了する前でも、星の状態をすぐに更新するためにそれを使用します。更新が最終的に失敗した場合、UIは実際のデータに戻ります。

👉 fetcher.formDataから楽観的な値を読み取る

app/routes/contacts.$contactId.tsx
// 既存のコード
 
const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  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>
  );
};

これで、星はクリックしたときにすぐに新しい状態に変わります。


以上です!Remixを試していただきありがとうございます。このチュートリアルが、優れたユーザーエクスペリエンスを構築するための堅実なスタートとなることを願っています。他にも多くのことができるので、すべてのAPIを確認してください 😀