コンテンツにスキップ

チュートリアル (30分)

Remix チュートリアル

連絡先を管理できる小規模ながら機能豊富なアプリケーションを構築します。Remix に焦点を当てるため、データベースやその他の「本番環境向け」の機能は使用しません。このチュートリアルに従って実践する場合は約 30 分かかりますが、さっと読むだけなら数分で済みます。

👉 このマークが表示されるたびに、アプリケーションで何かを実行する必要があります!

その他の内容は参考情報として、理解を深めるためのものです。では始めましょう。

セットアップ

👉 基本テンプレートを生成する

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

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

👉 アプリケーションを起動する

Terminal window
# アプリケーションディレクトリに移動
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="ja">
<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="連絡先を検索"
id="q"
name="q"
placeholder="検索"
type="search"
/>
<div
aria-hidden
hidden={true}
id="search-spinner"
/>
</Form>
<Form method="post">
<button type="submit">新規</button>
</Form>
</div>
<nav>
<ul>
<li>
<a href={`/contacts/1`}>あなたの名前</a>
</li>
<li>
<a href={`/contacts/2`}>あなたの友達</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 ページが表示されます。URL “/contacts/1” にマッチするルートを作成しましょう。

👉 app/routes ディレクトリと連絡先ルートモジュールを作成する

Terminal window
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: "あなたの",
last: "名前",
avatar: "https://placecats.com/200/200",
twitter: "your_handle",
notes: "メモ",
favorite: true,
};
return (
<div id="contact">
<div>
<img
alt={`${contact.first} ${contact.last} のアバター`}
key={contact.avatar}
src={contact.avatar}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>名前なし</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">編集</button>
</Form>
<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm(
"このレコードを削除してもよろしいですか?"
);
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">削除</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
? "お気に入りから削除"
: "お気に入りに追加"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "" : ""}
</button>
</Form>
);
};

リンクをクリックするか /contacts/1 にアクセスすると… 何も変わりません?

メインコンテンツが空白の連絡先ルート

ネストされたルートと Outlet

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

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

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

これで子ルートが Outlet を通じてレンダリングされるはずです。

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

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

気づいたかもしれませんし、気づかなかったかもしれませんが、サイドバーのリンクをクリックすると、ブラウザは次の 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="ja">
{/* その他の要素 */}
<body>
<div id="sidebar">
{/* その他の要素 */}
<nav>
<ul>
<li>
<Link to={`/contacts/1`}>あなたの名前</Link>
</li>
<li>
<Link to={`/contacts/2`}>あなたの友達</Link>
</li>
</ul>
</nav>
</div>
{/* その他の要素 */}
</body>
</html>
);
}

ブラウザの開発者ツールでネットワークタブを開くと、もはやドキュメントをリクエストしていないことがわかります。

データの読み込み

URL セグメント、レイアウト、そしてデータは通常一緒に結びついています(三位一体?)。このアプリケーションでもすでにそれが見られます:

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

この自然な結びつきがあるため、Remix にはルートコンポーネントにデータを簡単に取り込むためのデータ規約があります。

データを読み込むために、[loader][loader] と [useLoaderData][use-loader-data] という 2 つの API を使用します。まず、ルートルートで 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="ja">
{/* その他の要素 */}
<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
// existing imports & exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
// 既存のコード
}

ローダーでの URL パラメーター

👉 サイドバーのリンクの 1 つをクリックしてください

以前の静的な連絡先ページが表示されますが、1 つ違いがあります:URL に実際のレコード ID が含まれています。

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

これらの 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, "contactIdパラメーターが見つかりません");
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, "contactIdパラメーターが見つかりません");
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("見つかりません", { status: 404 });
}
return json({ contact });
};
// 既存のコード

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

データの変更

最初の連絡先をすぐに作成しますが、まずは HTML について話しましょう。

Remix は HTML フォームのナビゲーションをデータ変更のプリミティブとしてシミュレートします。これは、JavaScript のカンブリア爆発以前には唯一の方法でした。その単純さに惑わされないでください!Remix のフォームは、クライアントサイドレンダリングアプリケーションの UX 機能と「古き良き」Web モデルのシンプルさを両立させています。

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

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

アプリケーションの 新規 ボタンをクリックすることで、これをテストできます。

サーバー上にこのフォームナビゲーションを処理するコードがないため、Remix は 405 を返しています。

連絡先の作成

ルートルートで「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 は?!

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

Web のセマンティクスでは、「POST」は通常、何らかのデータが変更されることを示します。慣例として、Remix はこれをヒントとして使用し、「アクション」が完了した後にページ上のデータを自動的に再検証します。

実際、これは単なる HTML と HTTP なので、JavaScript を無効にしても全体のプロセスは正常に動作します。Remix がフォームをシリアライズしてfetchリクエストをサーバーに送信する代わりに、ブラウザがフォームをシリアライズしてドキュメントリクエストを行います。そこから、Remix はサーバーサイドでページをレンダリングして送信します。どちらの方法でも、最終的な UI は同じです。

私たちは JavaScript を保持します。なぜなら、スピナーと静的なドキュメントよりも良いユーザーエクスペリエンスを提供したいからです。

データの更新

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

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

👉 編集コンポーネントを作成する

Terminal window
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, "contactIdパラメーターが見つかりません");
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("見つかりません", { 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>名前</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 関数を追加することだけです。Remix はフォームをシリアライズし、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, "contactIdパラメーターが見つかりません");
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(params.contactId, updates);
return redirect(`/contacts/${params.contactId}`);
};
// 既存のコード

フォームに入力して保存をクリックすると、このような画面が表示されるはずです!(ただし、見た目はもっと快適で、おそらくこれほど毛むくじゃらではありません。)

ミューテーションについての議論

😑 動作はしましたが、ここで何が起きているのかよくわかりません…

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

contacts.$contactId_.edit.tsx を開いて form 要素を見てください。それぞれに名前がついていることに注目してください:

// 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も含みます)。

フォーム内の各フィールドには 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を使用してすべてを 1 つのオブジェクトにまとめています。これは 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, "contactIdパラメーターが見つかりません");
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(params.contactId, updates);
return redirect(`/contacts/${params.contactId}`);
};

actionloader 関数は両方ともResponseを返すことができます(これは理にかなっています。なぜなら、それらはRequestを受け取るからです!)。redirectヘルパーは、アプリケーションに場所を変更するように指示するResponseを返すことを簡単にするだけです。

クライアントサイドルーティングがない場合、サーバーが POST リクエスト後にリダイレクトすると、新しいページは最新のデータを取得してレンダリングします。前に学んだように、Remix はこのモデルをシミュレートし、action 呼び出し後に自動的にページ上のデータを再検証します。これが、フォームを保存したときにサイドバーが自動的に更新される理由です。クライアントサイドルーティングがない場合、追加の再検証コードは存在しないので、Remix では、クライアントサイドルーティングがある場合でも必要ありません!

最後に一つ。JavaScript がない場合、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="ja">
{/* 既存の要素 */}
<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” に関数を渡していることに注目してください。ユーザーが ""と一致する URL にいる場合、“isActive”が true になります。アクティブになりそうな場合(データがまだ読み込み中)、“isPending” が true になります。これにより、ユーザーの現在位置を簡単に示し、リンクをクリックしてデータの読み込みが必要な場合に即座にフィードバックを提供できます。

グローバルなペンディング UI

ユーザーがアプリケーション内を移動する際、Remix は次のページのデータを読み込んでいる間、_古いページを維持_します。リスト間をクリックするとき、アプリケーションの応答が少し遅く感じられることに気づいたかもしれません。アプリケーションが応答しないように感じないよう、ユーザーにフィードバックを提供しましょう。

Remix はバックグラウンドですべての状態を管理し、動的な Web アプリケーションを構築するために必要な部分を公開しています。この例では、useNavigationフックを使用します。

👉 useNavigationを使用してグローバルなペンディング 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="ja">
{/* 既存の要素 */}
<body>
{/* 既存の要素 */}
<div
className={
navigation.state === "loading" ? "loading" : ""
}
id="detail"
>
<Outlet />
</div>
{/* 既存の要素 */}
</body>
</html>
);
}

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

この例では、アイドル状態でない場合、アプリケーションのメイン部分に “loading” クラスを追加します。その後、CSS が短い遅延の後に美しいフェードエフェクトを追加します(高速な読み込み時の UI のちらつきを避けるため)。ただし、トップにスピナーやローディングバーを表示するなど、好きなことを行うことができます。

レコードの削除

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

// app/routes/contact.$contactId.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.tsx 内でレンダリングされているため、destroy という相対アクションを指定すると、クリック時にフォームは contacts.$contactId.destroy に送信されます。

この時点で、削除ボタンを機能させるために必要なすべての知識を持っているはずです。先に進む前に、試してみませんか?必要なものは:

  1. 新しいルート
  2. そのルート上の action
  3. app/data.ts からコンタクトを削除する
  4. どこかに redirect する

👉 “destroy” ルートモジュールを作成する

Terminal window
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, "contactIdパラメーターが見つかりません");
await deleteContact(params.contactId);
return redirect("/");
};

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

😅 なぜこれがすべて機能するのかまだよくわかりません

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

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

「フォーム」を追加し、「アクション」を追加すれば、残りは Remix が処理します。

インデックスルート

アプリケーションを読み込むと、リストの右側に大きな空白のページがあることに気づくでしょう。

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

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

Terminal window
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 は特別です。これは、ユーザーが親ルートの正確なパスにいる場合(つまり、<Outlet /> 内でレンダリングする他の子ルートがない場合)に、このルートを一致させてレンダリングするよう Remix に指示します。

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

キャンセルボタン

編集ページにはキャンセルボタンがありますが、現時点では何の機能もありません。ブラウザの戻るボタンと同じ機能を果たすようにしたいと思います。

ボタンのクリックハンドラーと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 を変更するリンクか、データを「アクション」関数に投稿する「フォーム」でした。検索フィールドは面白いです。なぜなら、それは両方の混合だからです:それは「フォーム」ですが、データを変更することなく URL を変更するだけです。

検索フォームを送信すると何が起こるか見てみましょう:

👉 検索バーに名前を入力して Enter キーを押す

ブラウザの URL に現在、あなたのクエリが含まれていることに注意してください。形式はURLSearchParamsです:

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

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

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 関数を呼び出しません。GET form を送信することは、リンクをクリックするのと同じです: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="検索"
defaultValue={q || ""}
id="q"
name="q"
placeholder="検索"
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="検索"
id="q"
name="q"
// ユーザーの入力をコンポーネントの状態に同期させる
onChange={(event) =>
setQuery(event.currentTarget.value)
}
placeholder="検索"
type="search"
// `defaultValue`から`value`に切り替え
value={query}
/>
{/* 既存の要素 */}
</Form>
{/* 既存の要素 */}
</div>
{/* 既存の要素 */}
</div>
{/* 既存の要素 */}
</body>
</html>
);
}

これで、戻る / 進む / リフレッシュボタンをクリックすると、入力値が URL と結果に同期されるようになります。

FormonChange による送信

ここでは、製品の意思決定を行います。ユーザーが「フォーム」を送信して特定の結果をフィルタリングしたい場合もあれば、ユーザーが入力する際にフィルタリングを行いたい場合もあります。最初の実装はすでに行ったので、次に 2 番目の実装を見てみましょう。

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="ja">
{/* 既存の要素 */}
<body>
<div id="sidebar">
{/* 既存の要素 */}
<div>
<Form
id="search-form"
onChange={(event) =>
submit(event.currentTarget)
}
role="search"
>
{/* 既存の要素 */}
</Form>
{/* 既存の要素 */}
</div>
{/* 既存の要素 */}
</div>
{/* 既存の要素 */}
</body>
</html>
);
}

入力すると、「フォーム」が自動的に送信されます!

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

検索スピナーの追加

本番アプリケーションでは、この検索はデータベース内のレコードを検索することが多く、これらのレコードは一度に送信してクライアントでフィルタリングするには大きすぎます。これが、このデモにいくつかの擬似ネットワーク遅延がある理由です。

ローディングインジケーターがないため、検索は少し遅く感じられます。データベースをより速くすることができても、ユーザーのネットワーク遅延には常に直面することになりますが、これは私たちの制御下にはありません。

より良いユーザーエクスペリエンスを得るために、検索に即時の 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.location」は「未定義」になりますが、ユーザーがナビゲートすると、データが読み込まれるときに次の位置が埋められます。そして、彼らが「location.search」を使用して検索しているかどうかを確認します。

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

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="連絡先を検索"
className={searching ? "loading" : ""}
defaultValue={q || ""}
id="q"
name="q"
placeholder="検索"
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 回も戻る必要はありません。

ナビゲーションのないForm

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

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

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

👉 <Favorite>フォームをフェッチャーフォームに変更する

// 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, "contactIdパラメーターが見つかりません");
const formData = await request.formData();
return updateContact(params.contactId, {
favorite: formData.get("favorite") === "true",
});
};
// 既存のコード

これで、ユーザーの名前の横にある星をクリックする準備が整いました!

確認してみてください。2 つの星が自動的に更新されます。私たちの新しい <fetcher.Form method="post"> の動作は、これまで使用していた <Form> とほぼ同じです:それは action を呼び出し、すべてのデータを自動的に再検証します — エラーも同じ方法でキャッチされます。

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

楽観的なユーザーインターフェース

前のセクションでお気に入りボタンをクリックしたとき、アプリケーションが少し応答しなかったことに気づいたかもしれません。実際の世界では、再びいくつかのネットワーク遅延が発生します。

ユーザーにフィードバックを提供するために、fetcher.stateを使用して星をローディング状態に置くことができます(以前の navigation.state と非常に似ています)が、今回はもっと良いことができます。「楽観的 UI」と呼ばれる戦略を使用できます。

フェッチャーは「アクション」に送信された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 をぜひご確認ください😀