Remix チュートリアル
ここでは、連絡先を管理できる、小さくても機能豊富なアプリを構築します。データベースやその他の「本番環境向け」のものは含まれていないため、Remix に集中できます。このチュートリアルに沿って進めると約30分かかりますが、読むだけならすぐに終わります。
👉 このマークが表示されたら、アプリで何かをする必要があります!
それ以外の部分は、情報提供とより深い理解のためのものです。それでは始めましょう。
セットアップ
👉 基本的なテンプレートを生成する
npx create-remix@latest --template remix-run/remix/templates/remix-tutorialこれは非常にシンプルなテンプレートですが、CSSとデータモデルが含まれているため、Remix に集中できます。クイックスタートで Remix プロジェクトの基本的なセットアップについて詳しく学ぶことができます。
👉 アプリを起動する
# cd into the app directory
cd {wherever you put the app}
# install dependencies if you haven't already
npm install
# start the server
npm run devhttp://localhost:5173 を開くと、スタイルが適用されていない画面が表示されるはずです。
ルートルート
app/root.tsx というファイルに注目してください。これは「ルートルート」と呼ばれるものです。UI で最初にレンダリングされるコンポーネントなので、通常はページのグローバルレイアウトを含みます。
ルートコンポーネントのコードを見るにはここを展開してください
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>
);
}links を使ってスタイルシートを追加する
Remix アプリをスタイリングする方法は複数ありますが、Remix に集中するために、すでに書かれているプレーンなスタイルシートを使用します。
CSS ファイルは JavaScript モジュールに直接インポートできます。Vite はアセットにフィンガープリントを付け、ビルドのクライアントディレクトリに保存し、モジュールに公開アクセス可能な href を提供します。
👉 アプリのスタイルをインポートする
import type { LinksFunction } from "@remix-run/node";
// existing imports
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.tsxRemix の ルートファイル命名規則では、. は URL に / を作成し、$ はセグメントを動的にします。これにより、次のような URL に一致するルートを作成しました。
/contacts/123/contacts/abc
👉 連絡先コンポーネントの UI を追加する
たくさんの要素があるだけなので、コピー&ペーストしてください。
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 にアクセスしても...何も新しいことはありませんか?
ネストされたルートとアウトレット
Remix は React Router の上に構築されているため、ネストされたルーティングをサポートしています。子ルートが親レイアウト内でレンダリングされるためには、親で Outlet をレンダリングする必要があります。これを修正しましょう。app/root.tsx を開いて、内部に Outlet をレンダリングします。
👉 <Outlet /> をレンダリングする
// existing imports
import {
Form,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
// existing imports & code
export default function App() {
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">{/* other elements */}</div>
<div id="detail">
<Outlet />
</div>
{/* other elements */}
</body>
</html>
);
}これで、子ルートが Outlet を通してレンダリングされるはずです。
クライアントサイドルーティング
気づいたかもしれませんが、サイドバーのリンクをクリックすると、ブラウザはクライアントサイドルーティングではなく、次の URL に対して完全なドキュメントリクエストを行っています。
クライアントサイドルーティングにより、アプリはサーバーから別のドキュメントをリクエストすることなく URL を更新できます。代わりに、アプリはすぐに新しい UI をレンダリングできます。<Link> を使ってこれを実現しましょう。
👉 サイドバーの <a href> を <Link to> に変更する
// existing imports
import {
Form,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
// existing imports and exports
export default function App() {
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">
{/* other elements */}
<nav>
<ul>
<li>
<Link to={`/contacts/1`}>Your Name</Link>
</li>
<li>
<Link to={`/contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
</div>
{/* other elements */}
</body>
</html>
);
}ブラウザの開発者ツールのネットワークタブを開くと、ドキュメントがリクエストされなくなったことが確認できます。
データの読み込み
URL セグメント、レイアウト、データは、多くの場合、結合されています(3つ組?)。このアプリでもすでにそれが見られます。
| URL セグメント | コンポーネント | データ |
|---|---|---|
| / | <Root> | 連絡先リスト |
| contacts/:contactId | <Contact> | 個々の連絡先 |
この自然な結合のため、Remix にはルートコンポーネントにデータを簡単に取得するためのデータ規約があります。
データをロードするために使用する API は loader と useLoaderData の2つです。まず、ルートルートで loader 関数を作成してエクスポートし、次にデータをレンダリングします。
👉 app/root.tsx から loader 関数をエクスポートし、データをレンダリングする
// existing imports
import {
Form,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
// existing imports
import { getContacts } from "./data";
// existing exports
export const loader = async () => {
const contacts = await getContacts();
return { contacts };
};
export default function App() {
const { contacts } = useLoaderData();
return (
<html lang="en">
{/* other elements */}
<body>
<div id="sidebar">
{/* other elements */}
<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>
{/* other elements */}
</body>
</html>
);
}これだけです!Remix は、このデータを UI と自動的に同期させます。サイドバーは次のようになるはずです。
型推論
map 内の contact の型について TypeScript が文句を言っていることに気づいたかもしれません。typeof loader を使ってデータに関する型推論を得るために、簡単なアノテーションを追加できます。
// existing imports and exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
// existing code
}ローダー内の URL パラメータ
👉 サイドバーのリンクのいずれかをクリックする
古い静的な連絡先ページが再び表示されるはずですが、1つ違いがあります。URL にはレコードの実際の ID が含まれています。
app/routes/contacts.$contactId.tsx のファイル名にある $contactId の部分を覚えていますか?これらの動的セグメントは、URL のその位置にある動的な(変化する)値に一致します。URL 内のこれらの値を「URL パラメータ」、または略して「params」と呼びます。
これらの params は、動的セグメントに一致するキーを持つ loader に渡されます。たとえば、セグメントの名前が $contactId の場合、値は params.contactId として渡されます。
これらの params は、ID でレコードを検索するためによく使用されます。試してみましょう。
👉 連絡先ページに loader 関数を追加し、useLoaderData でデータにアクセスする
import { Form, useLoaderData } from "@remix-run/react";
// existing imports
import { getContact } from "../data";
export const loader = async ({ params }) => {
const contact = await getContact(params.contactId);
return { contact };
};
export default function Contact() {
const { contact } = useLoaderData<typeof loader>();
// existing code
}
// existing code
パラメータの検証とレスポンスのスロー
TypeScript が私たちに非常に怒っています。TypeScript を満足させて、それが私たちに何を考慮させるか見てみましょう。
import type { LoaderFunctionArgs } from "@remix-run/node";
// existing imports
import invariant from "tiny-invariant";
// existing imports
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
const contact = await getContact(params.contactId);
return { contact };
};
// existing codeこれが最初に浮き彫りにする問題は、ファイル名とコードの間でパラメータの名前を間違えた可能性があることです(ファイルの名前を変更したかもしれません!)。invariant は、コードに潜在的な問題があることを予期していた場合に、カスタムメッセージでエラーをスローするための便利な関数です。
次に、useLoaderData<typeof loader>() は、連絡先を取得したか null であるかを知っています(その ID を持つ連絡先がない可能性があります)。この潜在的な null はコンポーネントコードにとって扱いにくく、TS エラーがまだ飛び交っています。
コンポーネントコードで連絡先が見つからない可能性を考慮することもできますが、Web 的なやり方は適切な 404 を送信することです。これを loader で行い、すべての問題を一度に解決できます。
// existing imports
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 { contact };
};
// existing codeこれで、ユーザーが見つからない場合、このパスでのコード実行は停止し、Remix は代わりにエラーパスをレンダリングします。Remix のコンポーネントはハッピーパスにのみ集中できます😁
データミューテーション
すぐに最初の連絡先を作成しますが、まず HTML について話しましょう。
Remix は、HTML Form ナビゲーションをデータミューテーションのプリミティブとしてエミュレートします。これは、JavaScript のカンブリア爆発以前は唯一の方法でした。そのシンプルさに騙されてはいけません!Remix の Form は、クライアントレンダリングされたアプリの UX 機能と、「昔ながらの」Web モデルのシンプルさを提供します。
一部の Web 開発者には馴染みがないかもしれませんが、HTML の form は、リンクをクリックするのと同じように、ブラウザでナビゲーションを引き起こします。唯一の違いはリクエストにあります。リンクは URL のみを変更できますが、form はリクエストメソッド(GET と POST)とリクエストボディ(POST フォームデータ)も変更できます。
クライアントサイドルーティングがない場合、ブラウザは form のデータを自動的にシリアライズし、POST の場合はリクエストボディとして、GET の場合は URLSearchParams としてサーバーに送信します。Remix も同じことを行いますが、リクエストをサーバーに送信する代わりに、クライアントサイドルーティングを使用してルートの action 関数に送信します。
アプリの「New」ボタンをクリックして、これを試すことができます。
Remix は 405 を送信します。これは、このフォームナビゲーションを処理するコードがサーバーにないためです。
連絡先の作成
ルートルートで action 関数をエクスポートして、新しい連絡先を作成します。ユーザーが「New」ボタンをクリックすると、フォームはルートルートの action に POST されます。
👉 app/root.tsx から action 関数をエクスポートする
// existing imports
import { createEmptyContact, getContacts } from "./data";
export const action = async () => {
const contact = await createEmptyContact();
return { contact };
};
// existing codeこれだけです!「New」ボタンをクリックすると、リストに新しいレコードがポップアップ表示されるはずです🥳
createEmptyContact メソッドは、名前やデータなどがない空の連絡先を作成するだけです。しかし、それでもレコードは作成されます、約束します!
🧐 ちょっと待って...サイドバーはどのように更新されたの?
action関数はどこで呼び出されたの?データを再フェッチするコードはどこにあるの?useState、onSubmit、useEffectはどこに?
ここに「昔ながらの Web」プログラミングモデルが現れます。<Form> は、ブラウザがサーバーに新しいドキュメント POST リクエストを送信するのを防ぎ、代わりに fetch を使用してルートの action 関数に送信します。
Web のセマンティクスでは、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 を追加する
これまで見たことのないものは何もありません。コピー&ペーストしてください。
import type { LoaderFunctionArgs } 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 { 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>
);
}新しいレコードをクリックし、「Edit」ボタンをクリックしてください。新しいルートが表示されるはずです。
FormData を使って連絡先を更新する
先ほど作成した編集ルートはすでに form をレンダリングしています。必要なのは action 関数を追加することだけです。Remix は form をシリアライズし、fetch で POST し、すべてのデータを自動的に再検証します。
👉 編集ルートに action 関数を追加する
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
// existing imports
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}`);
};
// existing codeフォームに入力し、「Save」をクリックすると、次のようなものが表示されるはずです!(ただし、もっと見やすく、毛が少ないかもしれません。)
ミューテーションの議論
😑 うまくいったけど、何が起こっているのか全くわからない...
少し掘り下げてみましょう...
contacts.$contactId_.edit.tsx を開いて、form 要素を見てください。それぞれに名前があることに注目してください。
<input
aria-label="First name"
defaultValue={contact.first}
name="first"
placeholder="First"
type="text"
/>JavaScript がない場合、フォームが送信されると、ブラウザは FormData を作成し、それをリクエストのボディとしてサーバーに送信します。前述したように、Remix はこれを防ぎ、ブラウザをエミュレートして、代わりに fetch を使用してリクエストを action 関数に送信します。これには FormData も含まれます。
form の各フィールドは formData.get(name) でアクセスできます。たとえば、上記の入力フィールドの場合、名と姓は次のようにアクセスできます。
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 関数が求めているものです。
const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"action 関数を除いて、Remix はここで議論している API のどれも提供していません。request、request.formData、Object.fromEntries はすべて Web プラットフォームによって提供されています。
action が完了した後、末尾の redirect に注目してください。
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 は通常のリダイレクトになります。しかし、JavaScript がある場合、それはクライアントサイドのリダイレクトなので、ユーザーはスクロール位置やコンポーネントの状態のようなクライアントの状態を失いません。
新しいレコードを編集ページにリダイレクトする
リダイレクトの方法がわかったので、新しい連絡先を作成する action を更新して、編集ページにリダイレクトするようにしましょう。
👉 新しいレコードの編集ページにリダイレクトする
// existing imports
import { redirect } from "@remix-run/node";
// existing imports
export const action = async () => {
const contact = await createEmptyContact();
return redirect(`/contacts/${contact.id}/edit`);
};
// existing codeこれで「New」をクリックすると、編集ページに移動するはずです。
アクティブリンクのスタイリング
たくさんのレコードがあるため、サイドバーでどのレコードを見ているのかが明確ではありません。これを修正するために NavLink を使用できます。
👉 サイドバーの <Link> を <NavLink> に置き換える
// existing imports
import {
Form,
Links,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
// existing imports and exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
className={({ isActive, isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
to={`contacts/${contact.id}`}
>
{/* existing elements */}
</NavLink>
</li>
))}
</ul>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}className に関数を渡していることに注目してください。ユーザーが <NavLink to> に一致する URL にいる場合、isActive は true になります。アクティブになる直前(データがまだロード中)の場合、isPending は true になります。これにより、ユーザーがどこにいるかを簡単に示し、リンクがクリックされたときに即座にフィードバックを提供できますが、データはロードされる必要があります。
グローバルな保留中 UI
ユーザーがアプリをナビゲートする際、Remix は次のページのデータがロードされている間、_古いページを表示したまま_にします。リスト間をクリックすると、アプリが少し反応しないように感じることに気づいたかもしれません。アプリが反応しないように感じさせないように、ユーザーにフィードバックを提供しましょう。
Remix はすべての状態を舞台裏で管理し、動的な Web アプリを構築するために必要な要素を公開します。この場合、useNavigation フックを使用します。
👉 useNavigation を使用してグローバルな保留中 UI を追加する
// existing imports
import {
Form,
Links,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useNavigation,
} from "@remix-run/react";
// existing imports and exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
const navigation = useNavigation();
return (
<html lang="en">
{/* existing elements */}
<body>
{/* existing elements */}
<div
className={
navigation.state === "loading" ? "loading" : ""
}
id="detail"
>
<Outlet />
</div>
{/* existing elements */}
</body>
</html>
);
}useNavigation は現在のナビゲーション状態を返します。これは "idle"、"loading"、または "submitting" のいずれかになります。
この場合、アイドル状態でない場合は、アプリのメイン部分に "loading" クラスを追加します。CSS は短い遅延の後(高速ロードでの UI のちらつきを避けるため)に素敵なフェードを追加します。ただし、スピナーや上部にローディングバーを表示するなど、好きなようにできます。
レコードの削除
連絡先ルートのコードを確認すると、削除ボタンは次のようになっています。
<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 を持つ相対的な action は、クリックされたときにフォームを contacts.$contactId.destroy に送信します。
この時点で、削除ボタンを機能させるために必要なことはすべて知っているはずです。先に進む前に試してみませんか?必要なものは次のとおりです。
- 新しいルート
- そのルートでの
action app/data.tsからのdeleteContact- どこかへの
redirect
👉 「destroy」ルートモジュールを作成する
touch app/routes/contacts.\$contactId_.destroy.tsx👉 destroy action を追加する
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("/");
};よし、レコードに移動して「Delete」ボタンをクリックしてください。動作します!
😅 なぜこれがすべて機能するのか、まだ混乱しています
ユーザーが送信ボタンをクリックすると:
<Form>は、新しいドキュメントPOSTリクエストをサーバーに送信するというブラウザのデフォルトの動作を防ぎますが、代わりにクライアントサイドルーティングとfetchを使用してPOSTリクエストを作成することでブラウザをエミュレートします。<Form action="destroy">はcontacts.$contactId_.destroy.tsxの新しいルートに一致し、リクエストを送信します。actionがリダイレクトした後、Remix はページのデータに対してすべてのloaderを呼び出して最新の値を取得します(これが「再検証」です)。useLoaderDataは新しい値を返し、コンポーネントを更新させます!
Form を追加し、action を追加すれば、残りは Remix が行います。
インデックスルート
アプリをロードすると、リストの右側に大きな空白ページが表示されることに気づくでしょう。
ルートに子があり、親ルートのパスにいる場合、<Outlet> は一致する子がいないため何もレンダリングしません。インデックスルートは、そのスペースを埋めるデフォルトの子ルートと考えることができます。
👉 ルートルートのインデックスルートを作成する
touch app/routes/_index.tsx👉 インデックスコンポーネントの要素を埋める
コピー&ペーストしてください。特別なことは何もありません。
export default function Index() {
return (
<p id="index-page">
This is a demo for Remix.
<br />
Check out{" "}
<a href="https://remix.run">the docs at remix.run</a>.
</p>
);
}ルート名 _index は特別です。これは、ユーザーが親ルートの正確なパスにいるときに、Remix にこのルートを一致させてレンダリングするように指示します。そのため、<Outlet /> にレンダリングする他の子ルートはありません。
ほら!もう空白スペースはありません。ダッシュボード、統計、フィードなどをインデックスルートに配置するのは一般的です。これらはデータロードにも参加できます。
キャンセルボタン
編集ページには、まだ何も機能しないキャンセルボタンがあります。ブラウザの戻るボタンと同じ機能をさせたいと思います。
ボタンにクリックハンドラーと useNavigate が必要です。
👉 useNavigate を使ってキャンセルボタンのクリックハンドラーを追加する
// existing imports
import {
Form,
useLoaderData,
useNavigate,
} from "@remix-run/react";
// existing imports and exports
export default function EditContact() {
const { contact } = useLoaderData<typeof loader>();
const navigate = useNavigate();
return (
<Form key={contact.id} id="contact-form" method="post">
{/* existing elements */}
<p>
<button type="submit">Save</button>
<button onClick={() => navigate(-1)} type="button">
Cancel
</button>
</p>
</Form>
);
}これで、ユーザーが「Cancel」をクリックすると、ブラウザの履歴で1つ前のエントリに戻されます。
🧐 なぜボタンに
event.preventDefault()がないの?
<button type="button"> は、一見冗長に見えますが、ボタンがフォームを送信するのを防ぐ HTML の方法です。
あと2つの機能が残っています。もうすぐ終わりです!
URLSearchParams と GET 送信
これまでのすべてのインタラクティブな UI は、URL を変更するリンクか、action 関数にデータを POST する form のいずれかでした。検索フィールドは、その両方が混ざっているため興味深いものです。form ですが、URL を変更するだけで、データを変更しません。
検索フォームを送信するとどうなるか見てみましょう。
👉 検索フィールドに名前を入力して Enter キーを押す
ブラウザの URL に、URLSearchParams としてクエリが含まれていることに注目してください。
http://localhost:5173/?q=ryan
<Form method="post"> ではないため、Remix はブラウザをエミュレートし、FormData をリクエストボディではなく URLSearchParams にシリアライズします。
loader 関数は request から検索パラメータにアクセスできます。これを使用してリストをフィルタリングしましょう。
👉 URLSearchParams がある場合はリストをフィルタリングする
import type {
LinksFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports and exports
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return { contacts };
};
// existing code
これは GET であり POST ではないため、Remix は action 関数を呼び出しません。GET form を送信することは、リンクをクリックするのと同じです。URL のみが変更されます。
これはまた、通常のページナビゲーションを意味します。戻るボタンをクリックして元の場所に戻ることができます。
URL とフォームの状態の同期
ここでは、すぐに解決できるいくつかの UX の問題があります。
- 検索後に「戻る」をクリックすると、リストがフィルタリングされなくなっても、フォームフィールドには入力した値が残っています。
- 検索後にページを更新すると、リストがフィルタリングされていても、フォームフィールドに値が残っていません。
言い換えれば、URL と入力の状態が同期していません。
まず (2) を解決し、URL の値で入力を開始しましょう。
👉 loader から q を返し、それを入力のデフォルト値として設定する
// existing imports and exports
export const loader = async ({
request,
}: LoaderFunctionArgs) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return { contacts, q };
};
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form id="search-form" role="search">
<input
aria-label="Search contacts"
defaultValue={q || ""}
id="q"
name="q"
placeholder="Search"
type="search"
/>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}これで、検索後にページを更新すると、入力フィールドにクエリが表示されるようになります。
次に問題 (1) です。戻るボタンをクリックして入力を更新します。React の useEffect を使用して、DOM 内の入力の値を直接操作できます。
👉 入力値を URLSearchParams と同期させる
// existing imports
import { useEffect } from "react";
// existing imports and exports
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]);
// existing code
}🤔 これには制御されたコンポーネントと React State を使うべきではないの?
確かに、これを制御されたコンポーネントとして行うこともできます。同期ポイントは増えますが、それはあなた次第です。
展開してその様子を見る
// We no longer need useEffect
import { useState } from "react";
// existing imports and exports
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// the query now needs to be kept in state
const [prevQ, setPrevQ] = useState(q);
const [query, setQuery] = useState(q || "");
// We can avoid using `useEffect` to synchronize the query
// by using a separate piece of state to store the previous
// value
if (q !== prevQ) {
setPrevQ(q);
setQuery(q || "");
}
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form id="search-form" role="search">
<input
aria-label="Search contacts"
id="q"
name="q"
// synchronize user's input to component state
onChange={(event) =>
setQuery(event.currentTarget.value)
}
placeholder="Search"
type="search"
// switched to `value` from `defaultValue`
value={query}
/>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}これで、戻る/進む/更新ボタンをクリックしても、入力値が URL と結果と同期するはずです。
Form の onChange での送信
ここで製品に関する決定を下す必要があります。ユーザーに form を送信させて結果をフィルタリングしたい場合もあれば、ユーザーが入力するたびにフィルタリングしたい場合もあります。前者はすでに実装したので、後者がどのようなものか見てみましょう。
useNavigate はすでに見てきましたが、ここではその仲間である useSubmit を使用します。
// existing imports
import {
Form,
Links,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useNavigation,
useSubmit,
} from "@remix-run/react";
// existing imports and exports
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const submit = useSubmit();
// existing code
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form
id="search-form"
onChange={(event) =>
submit(event.currentTarget)
}
role="search"
>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}入力すると、form が自動的に送信されるようになりました!
submit の引数に注目してください。submit 関数は、渡された任意のフォームをシリアライズして送信します。ここでは event.currentTarget を渡しています。currentTarget は、イベントがアタッチされている DOM ノード(form)です。
検索スピナーの追加
本番環境のアプリでは、この検索は、一度にすべてを送信してクライアントサイドでフィルタリングするには大きすぎるデータベース内のレコードを探している可能性が高いです。そのため、このデモでは偽のネットワーク遅延が設定されています。
ローディングインジケーターがないと、検索が少しもっさり感じられます。データベースを高速化できたとしても、常にユーザーのネットワーク遅延が邪魔になり、私たちの制御外です。
より良いユーザーエクスペリエンスのために、検索に即座の UI フィードバックを追加しましょう。ここでも useNavigation を使用します。
👉 検索中かどうかを知るための変数を追加する
// existing imports and exports
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"
);
// existing code
}何も起こっていないときは navigation.location は undefined ですが、ユーザーがナビゲートすると、データがロードされている間、次の location で値が設定されます。次に、location.search を使用して検索中かどうかを確認します。
👉 新しい searching 状態を使用して検索フォーム要素にクラスを追加する
// existing imports and exports
export default function App() {
// existing code
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<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>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}おまけとして、検索中にメイン画面がフェードアウトするのを避けます。
// existing imports and exports
export default function App() {
// existing code
return (
<html lang="en">
{/* existing elements */}
<body>
{/* existing elements */}
<div
className={
navigation.state === "loading" && !searching
? "loading"
: ""
}
id="detail"
>
<Outlet />
</div>
{/* existing elements */}
</body>
</html>
);
}これで、検索入力の左側に素敵なスピナーが表示されるはずです。
履歴スタックの管理
フォームはキーストロークごとに送信されるため、「alex」と入力してからバックスペースで削除すると、巨大な履歴スタックができてしまいます 😂。これは絶対に避けたいことです。
これを避けるには、履歴スタックにプッシュする代わりに、現在のエントリを次のページで_置き換える_ことができます。
👉 submit で replace を使用する
// existing imports and exports
export default function App() {
// existing code
return (
<html lang="en">
{/* existing elements */}
<body>
<div id="sidebar">
{/* existing elements */}
<div>
<Form
id="search-form"
onChange={(event) => {
const isFirstSearch = q === null;
submit(event.currentTarget, {
replace: !isFirstSearch,
});
}}
role="search"
>
{/* existing elements */}
</Form>
{/* existing elements */}
</div>
{/* existing elements */}
</div>
{/* existing elements */}
</body>
</html>
);
}これが最初の検索かどうかを素早く確認した後、置き換えるかどうかを決定します。これで、最初の検索は新しいエントリを追加しますが、それ以降のすべてのキーストロークは現在のエントリを置き換えます。検索を削除するために7回戻るボタンをクリックする代わりに、ユーザーは1回だけ戻るボタンをクリックすればよくなります。
ナビゲーションなしの Form
これまでのすべてのフォームは URL を変更してきました。これらのユーザーフローは一般的ですが、ナビゲーションを引き起こさずにフォームを送信したい場合も同様に一般的です。
このような場合、useFetcher があります。これにより、ナビゲーションを引き起こすことなく action や loader と通信できます。
連絡先ページの ★ ボタンはこれに理にかなっています。新しいレコードを作成したり削除したりするわけではなく、ページを変更したいわけでもありません。単に表示しているページのデータを変更したいだけです。
👉 <Favorite> フォームをフェッチャーフォームに変更する
// existing imports
import {
Form,
useFetcher,
useLoaderData,
} from "@remix-run/react";
// existing imports and exports
// existing code
const Favorite: FunctionComponent<{
contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
const fetcher = useFetcher();
const favorite = contact.favorite;
return (
<fetcher.Form method="post">
<button
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
};このフォームはナビゲーションを引き起こさず、単に action にフェッチするだけになります。そういえば... action を作成するまでこれは機能しません。
👉 action を作成する
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports
import { getContact, updateContact } from "../data";
// existing imports
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",
});
};
// existing codeよし、ユーザー名の横にある星をクリックする準備ができました!
見てください、両方の星が自動的に更新されます。新しい <fetcher.Form method="post"> は、これまで使用してきた <Form> とほぼ同じように機能します。action を呼び出し、すべてのデータが自動的に再検証されます。エラーも同じように捕捉されます。
ただし、1つの重要な違いがあります。これはナビゲーションではないため、URL は変更されず、履歴スタックも影響を受けません。
オプティミスティック UI
前回のセクションで、お気に入りボタンをクリックしたときにアプリが少し反応しないように感じたかもしれません。ここでも、実際の環境ではネットワーク遅延が発生するため、偽のネットワーク遅延を追加しました。
ユーザーにフィードバックを提供するために、fetcher.state(以前の navigation.state と非常によく似ています)を使用して星をローディング状態にすることもできますが、今回はさらに良いことができます。「オプティミスティック UI」と呼ばれる戦略を使用できます。
フェッチャーは action に送信される FormData を知っているため、fetcher.formData で利用できます。これを使用して、ネットワークが完了していなくても、星の状態を即座に更新します。更新が最終的に失敗した場合、UI は実際のデータに戻ります。
👉 fetcher.formData からオプティミスティックな値を読み取る
// existing code
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
? "Remove from favorites"
: "Add to favorites"
}
name="favorite"
value={favorite ? "false" : "true"}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
};これで、星はクリックすると_即座に_新しい状態に変わります。
これで終わりです!Remix を試していただきありがとうございます。このチュートリアルが、素晴らしいユーザーエクスペリエンスを構築するための確かなスタートとなることを願っています。できることは他にもたくさんありますので、すべての API をぜひチェックしてください 😀
- セットアップ
- ルートルート
- links を使ってスタイルシートを追加する
- 連絡先ルートの UI
- ネストされたルートとアウトレット
- クライアントサイドルーティング
- データの読み込み
- 型推論
- ローダー内の URL パラメータ
- パラメータの検証とレスポンスのスロー
- データミューテーション
- 連絡先の作成
- データの更新
- FormData を使って連絡先を更新する
- ミューテーションの議論
- 新しいレコードを編集ページにリダイレクトする
- アクティブリンクのスタイリング
- グローバルな保留中 UI
- レコードの削除
- インデックスルート
- キャンセルボタン
- URLSearchParams と GET 送信
- URL とフォームの状態の同期
- Form の onChange での送信
- 検索スピナーの追加
- 履歴スタックの管理
- ナビゲーションなしの Form
- オプティミスティック UI