Remix の チュートリアルをやろう
このスクラップについて
Remix 公式ドキュメントの 30 分チュートリアルを学習する過程を記録していく。
プロジェクト作成
npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
remix v2.11.2 💿 Let's build a better website...
dir Where should we create your new project?
remix-tutorial
◼ Template: Using remix-run/remix/templates/remix-tutorial...
✔ Template copied
git Initialize a new git repository?
Yes
deps Install dependencies with npm?
Yes
✔ Dependencies installed
✔ Git initialized
done That's it!
Enter your project directory using cd ./remix-tutorial
Check out README.md for development and deploy instructions.
Join the community at https://rmx.as/discord
サーバー起動
cd remix-tutorial
npm run dev
CSS 読み込み
import { LinksFunction } from "@remix-run/node";
import {
Form,
Links,
Meta,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import appStylesHref from "./app.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: appStylesHref },
];
スタイルシートが適用された。
import appStylesHref from "./app.css?url";
という書き方に興味がある。
URL としてインポートするようだ。
ファイル作成
mkdir app/routes
touch app/routes/contacts.\$contactId.tsx
コーディング
コピーして OK ですよと書いてあるけどせっかくなので入力していこう。
import { Form } from "@remix-run/react";
import { FunctionComponent } from "react";
import { ContactRecord } from "~/data";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://placekitten.com/200/200",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
return (
<div id="contact">
<div>
<img
src={contact.avatar}
key={contact.avatar}
alt={`${contact.first} ${contact.last} avatar`}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
<Favorite contact={contact}></Favorite>
</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="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>
);
};
// 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>
);
}
猫の画像が表示されていないがサーバーがダウンしているようなので仕方ない。
クライアントサイドルーティング
// existing imports
import {
Form,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
// existing imports & 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>
);
}
変化が分かりにくいがページの再読み込みが行われないようになっている。
データのロード
// existing imports
import { json } from "@remix-run/node";
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 json({ contacts });
};
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
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>
);
}
辛抱たまらなかったので useLoaderData に先走って型推論を追加してしまった。
詳細ページのデータロード
import { LoaderFunctionArgs } from "@remix-run/node";
import { Form, json, useLoaderData } from "@remix-run/react";
import { FunctionComponent } from "react";
import invariant from "tiny-invariant";
import { ContactRecord, getContact } from "~/data";
export const loader = async ({ params }: LoaderFunctionArgs) => {
invariant(params.contactId, "Missing contactId param");
const contact = await getContact(params.contactId);
return json({ contact });
};
export default function Contact() {
const { contact } = useLoaderData<typeof loader>();
if (!contact) {
throw new Response("Not found", { status: 404 });
}
// existing code
}
Edit ボタンを忘れていることに気がついた。
Not found メッセージはどこにいったのだろう。
次は Data Mutations
Data Mutations
// existing imports
import { createEmptyContact, getContacts } from "./data";
export const action = async () => {
const contact = await createEmptyContact();
return json({ contact });
};
// existing code
New ボタンをクリックするとページの再読み込みなしで更新されるのが素晴らしい。
Updating Data
touch app/routes/contacts.\$contactId_.edit.tsx
アンダーバーは何なのだろう?
Note the weird _ in
contactId.tsx. Read more in the Route File Naming guide. contactId_. By default, routes will automatically nest inside routes with the same prefixed name. Adding a trailing _ tells the route to not nest inside app/routes/contacts.
末尾に _
をつけることでネストしないようになるようだ。
import { LoaderFunctionArgs } from "@remix-run/node";
import { Form, json, 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
defaultValue={contact.first}
aria-label="First name"
name="first"
type="text"
placeholder="First"
/>
<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}></textarea>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}
次は Updating Contacts with FormData
Updating Contacts with FormData
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node";
import { json, 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
const updates = Object.fromEntries(formData);
という書き方が気になる。
lib.dom.iterable.d.ts で定義を見つけた
interface FormData {
[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>;
/** Returns an array of key, value pairs for every entry in the list. */
entries(): IterableIterator<[string, FormDataEntryValue]>;
/** Returns a list of keys in the list. */
keys(): IterableIterator<string>;
/** Returns a list of values in the list. */
values(): IterableIterator<FormDataEntryValue>;
}
ようやく理解できた。
Redirecting new records to the edit page
// existing imports
import { json, redirect } from "@remix-run/node";
// existing imports
export const action = async () => {
const contact = await createEmptyContact();
return redirect(`/contacts/${contact.id}/edit`);
};
// existing code
Active Link Styling
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>
);
}
選択されているコンタクトがわかりやすくなった。
Global Pending UI
// existing imports
import {
Form,
Links,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useNavigation,
} from "@remix-run/react";
// existing imports & 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>
);
}
変化が分かりにくかったので app/data.ts を編集して get メソッドに 1 秒間のスリープを追加したらようやく見えるようになった。
Deleting Records
touch app/routes/contacts.\$contactId.destroy.tsx
import { ActionFunctionArgs, 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 params");
await deleteContact(params.contactId);
return redirect("/");
};
最初 404 Not Found となって色々やっていたら同じコードで動作するようになった。
変更が反映されるタイミングなどがわからない。
Index Routes
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>
);
}
ルートにアクセスした時にスタートページ的なものが表示されるようになった
Cancel Button
// existing imports
import {
Form,
useLoaderData,
useNavigate,
} from "@remix-run/react";
// existing imports & 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>
);
}
type が button のボタンは押しても何も起こらないので preventDefault の呼び出しがいらないとのことだ。
しばらくやっていないと HTML 標準の動作を忘れてしまうな。
URLSearchParams and GET Submissions
import type {
LinksFunction,
LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports & 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 json({ contacts });
};
// existing code
キーワードを入力して Enter キーを押すと部分一致で絞り込まれる
ブラウザの履歴に残るので戻るボタンで戻ることができる、便利だ。
Synchronizing URLs to Form State
先ほどの実装にはまだ不完全なところがあった、具体的には下記の 2 つ。
- 戻るボタンを押しても検索テキストが戻らない
- ページをリロードすると検索テキストが空欄になる
まず 2. の方に対処してみる。
// existing imports & 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 json({ 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. の方に対処する。
// existing imports
import { useEffect, useState } from "react";
// existing imports & exports
export default function App() {
const { contacts, q } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// the query now needs to be kept in state
const [query, setQuery] = useState(q || "");
// we still have a `useEffect` to synchronize the query
// to the component state on back/forward button clicks
useEffect(() => {
setQuery(q || "");
}, [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>
);
}
無事に解決した。
次は Submitting Form's onChange
Submitting Form's onChange
// existing imports
import {
Form,
Links,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useNavigation,
useSubmit,
} from "@remix-run/react";
// existing imports & 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>
);
}
event.currentTarget については下記の通り、イベントハンドラが設定された要素になるようだ。
event.target だと恐らく input 要素になるのだろう。
Adding Search Spinner
// existing imports & 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"
);
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>
);
}
hidden 属性とは知らなかった。
これは何に使うのだろう?
続きは Bonus points から。
// existing imports & 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>
);
}
これにより、検索時にフェードアウトしないようになった。
Managing the History Stack
このセクションでは検索キーワードの変更がブラウザバックの履歴に残らないようにするようだ。
// existing imports & 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>
);
}
Forms Without Navigation
このセクションではナビゲーションを行わずにフォームを送信する方法について解説するようだ。
そういえばこれまでのフォームは全てナビゲーションが行われていたようだ。
// existing imports
import {
Form,
useFetcher,
useLoaderData,
} from "@remix-run/react";
// existing imports & 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>
);
};
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
Optimistic UI
このセクションではお気に入りに登録したら、素早く星形アイコンが切り替わるようにするようだ。
// 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 は Web 標準に則っているので予測しやすくてありがたい。
まだまだわからないことはたくさんあるけど魅力的なフレームワークに感じた。
他にも Remix について学びたいことはたくさんあるが、一旦これで Remix の学習は中断しよう。
次は未完了になっている Rust 学習のスクラップを終わらせようかな。