はじめに
ふぁるです。
フロントエンドを実装するエンジニアとしてなんやかんや生きております。
最近Remixが話題ですね。
数年前に一瞬流行って少しの間名前を聞かなかったフレームワークがどうしたのかしら。と、軽く調べてみましたが、SSR一本の武器で戦っていた彼がSPAという剣を手に取ったようですね。
今晩は、ビールを片手にRemixについて公式ドキュメントを読み込み、重要そうなところがあればまとめてみたり、実践してみようと思います。
最後まで読んでいただければ幸いに存じます。
書くこと、書かないこと
- 書くこと
- Remixの基本的な構文
- Remixのディレクトリ規則
- チュートリアルの内容
- 書かないこと
- SPA, SSRの違い等の基礎的な内容
- JavaScript, TypeScript, React等の基本的な構文
- 本記事はデフォルトのSSRモードで記述しますが、SSR -> SPAのマイグレーションは割と楽に出来るよう用意してもらえてそうです。
Remixの基本
ディレクトリ・ファイルの命名ルール
基本の形式
基本のプロジェクトのディレクトリの形式は以下です。
app/
├── routes/
└── root.tsx
app/root.tsx
はルートのルート(「発音が同じの言語の方はすみません」みたいなこと書いてあって面白くて引用しました)で、Next.js
やNuxt.js
でいうところのトップのlayout
っぽいです。
(app/root.tsx
については後述します。)
ファイルを追加する場合、以下のような形となります。
app/
├── routes/
│ ├── _index.tsx
│ └── about.tsx
└── root.tsx
app/routes
に_index.tsx
とabout.tsx
が追加されたことで、以下のURLにページが配置されることになります。
URL | 割り当てられるページファイル |
---|---|
/ |
app/routes/_index.tsx |
/about |
app/routes/about.tsx |
ドット区切り文字
さらに以下のようにした場合、
app/
├── routes/
│ ├── _index.tsx
│ ├── about.tsx
│ ├── concerts.trending.tsx
│ ├── concerts.salt-lake-city.tsx
│ └── concerts.san-diego.tsx
└── root.tsx
URL | 割り当てられるページファイル |
---|---|
/ |
app/routes/_index.tsx |
/about |
app/routes/about.tsx |
/concerts/trending |
app/routes/concerts.trending.tsx |
/concerts/salt-lake-city |
app/routes/concerts.salt-lake-city.tsx |
/concerts/san-diego |
app/routes/concerts.san-diego.tsx |
こうなります。
.
がネストを作成するようです。
まだ許せます。
動的セグメント
動的セグメント(/posts/:id
みたいなやつ)です。
以下のようなディレクトリ構成となります。
app/
├── routes/
│ ├── _index.tsx
│ ├── about.tsx
│ ├── concerts.$city.tsx
│ └── concerts.trending.tsx
└── root.tsx
URL | 割り当てられるページファイル |
---|---|
/ |
app/routes/_index.tsx |
/about |
app/routes/about.tsx |
/concerts/salt-lake-city |
app/routes/concerts.$city.tsx |
/concerts/san-diego |
app/routes/concerts.$city.tsx |
/concerts/trending |
app/routes/concerts.trending.tsx |
この辺から個人的にはだいぶ嫌悪感がすごいんですが、みなさん受け入れられますか。
ネスト済ルート(Nested Route)
app/
├── routes/
│ ├── _index.tsx
│ ├── about.tsx
│ ├── concerts._index.tsx
│ ├── concerts.$city.tsx
│ ├── concerts.trending.tsx
│ └── concerts.tsx
└── root.tsx
個人的にはこの思想は好きです。
(幾ら公式ドキュメントでも、このレベルはもうコロケーションしちまおうぜ、とは思うんですが)
具体的な解説ですが、Remixでは子コンポーネントは<Outlet />
にルーティングされます。
(チュートリアルの内容は後述します。)
<div style={{ backgroundColor: 'red' }}>
<Outlet />
</div>
の時、routes以下の全てのページにはbackgroundColor: 'red'
が適用されるわけですが、
<div style={{ color: 'blue' }}>
<Outlet />
</div>
となっている時、concerts.xxx.tsx
のページは全てbackgroundColor: 'red'
、color: 'blue'
で表示が行われます。
<Root>
<Conserts>
<(concerts.xxx) />
</Conserts>
</Root>
こうなるわけですね。
最近レイアウトをネストさせるの流行ってる気がします。気のせいですかね?
レイアウトをネストさせないネスト
app/
├── routes/
│ ├── _index.tsx
│ ├── about.tsx
│ ├── concerts.$city.tsx
│ ├── concerts.trending.tsx
│ ├── concerts.tsx
│ └── concerts_.mine.tsx
└── root.tsx
conserts_
のように、末尾にアンダースコアを付けることで<Concerts />
のレイアウトの呪縛から解き放たれるようです。
ドットでネストを作ってしまう特性上しょうがないと思うんですが、この仕様は慣れるまでの拒否感が凄そうですね。
ネストのグルーピング
app/
├── routes/
│ ├── _auth.login.tsx
│ ├── _auth.register.tsx
│ ├── _auth.tsx
│ ├── _index.tsx
│ ├── concerts.$city.tsx
│ └── concerts.tsx
└── root.tsx
URL | 一致したルート | レイアウト |
---|---|---|
/app/routes/_index.tsx |
app/root.tsx |
|
/login |
app/routes/_auth.login.tsx |
app/routes/_auth.tsx |
/register |
app/routes/_auth.register.tsx |
app/routes/_auth.tsx |
/concerts/salt-lake-city |
app/routes/concerts.$city.tsx |
app/routes/concerts.tsx |
うわぁ......
この辺結構限界ですね......
ただ、実際Next.js AppRouterの()
によるグルーピングはとてもいい感じ(最初拒否感凄かった)なのでもしかしたら好きになれるかもしれません。
任意セグメント
app/
├── routes/
│ ├── ($lang)._index.tsx
│ ├── ($lang).$productId.tsx
│ └── ($lang).categories.tsx
└── root.tsx
URL | ファイルパス |
---|---|
/ |
app/routes/($lang)._index.tsx |
/categories |
app/routes/($lang).categories.tsx |
/en/categories |
app/routes/($lang).categories.tsx |
/fr/categories |
app/routes/($lang).categories.tsx |
/american-flag-speedo |
app/routes/($lang)._index.tsx |
/en/american-flag-speedo |
app/routes/($lang).$productId.tsx |
/fr/american-flag-speedo |
app/routes/($lang).$productId.tsx |
だいぶ黒魔法ですね。
特に/american-flag-speedo
の部分が気になったので、引用します。
You may wonder why
/american-flag-speedo
is matching the($lang)._index.tsx
route instead of($lang).$productId.tsx
. This is because when you have an optional dynamic param segment followed by another dynamic param, Remix cannot reliably determine if a single-segment URL such as/american-flag-speedo
should match/:lang
/:productId
. Optional segments match eagerly and thus it will match/:lang
. If you have this type of setup it's recommended to look at params.lang in the($lang)._index.tsx
loader and redirect to/:lang/american-flag-speedo
for the current/default language if params.lang is not a valid language code.
何言ってんのかよくわかんないのでGPTに訳してもらいます。
/american-flag-speedo
が($lang)._index.tsx
ルートにマッチする理由について疑問を持つかもしれません。これは、オプショナルな動的パラメータセグメントに続いて別の動的パラメータがある場合、Remixでは/american-flag-speedo
のような単一セグメントのURLが/:lang
や/:productId
にマッチするかどうかを確実に判断できないためです。オプショナルセグメントは貪欲にマッチするため、/:lang
にマッチします。このタイプのセットアップを持っている場合、($lang)._index.tsx
のローダーでparams.lang
を確認し、params.lang
が有効な言語コードでない場合は現在の/デフォルト言語の/:lang/american-flag-speedo
にリダイレクトすることをお勧めします。
結局いまいち何言ってんのかマジでわかんないんですが、
オプショナルであっても:lang
がParams拾いに行くから、/american-flag-speedo
でapp/routes/($lang).$productId.tsx
にマッピングさせたいなら、デフォルト言語適当に入れて/unko/american-flag-speedo
とかにでもリダイレクトさせた方がええで
みたいな感じですかね。
(無理やりリダイレクトさせてまで使いたくないなぁこの機能......)
スプラットルート
app/
├── routes/
│ ├── _index.tsx
│ ├── $.tsx
│ ├── about.tsx
│ └── files.$.tsx
└── root.tsx
URL | Matched Route |
---|---|
/ |
app/routes/_index.tsx |
/beef/and/cheese |
app/routes/$.tsx |
/files |
app/routes/files.$.tsx |
/files/talks/remix-conf_old.pdf |
app/routes/files.$.tsx |
/files/talks/remix-conf_final.pdf |
app/routes/files.$.tsx |
/files/talks/remix-conf-FINAL-MAY_2022.pdf |
app/routes/files.$.tsx |
えええええwって感じですね。
なんなんだこの機能w
・・・と思ったんですが、以下を見るとなんとなく良い感じな気もしてきました。
export async function loader({
params,
}: LoaderFunctionArgs) {
const filePath = params["*"];
return fake.getFileInfo(filePath);
}
/files/
から始まるURLでサーバー上のファイルパスを指定したとき、そのファイルの情報が取れるとか(他にも活用事例はあると思いますが)考えるとこいつは有用な気がしました。
特殊文字のエスケープ
面白いと思います。
特に触れません。
ディレクトリの整理
app/
├── routes/
│ ├── _landing._index/
│ │ ├── route.tsx
│ │ └── scroll-experience.tsx
│ ├── _landing.about/
│ │ ├── employee-profile-card.tsx
│ │ ├── get-employee-data.server.tsx
│ │ ├── route.tsx
│ │ └── team-photo.jpg
│ ├── _landing/
│ │ ├── footer.tsx
│ │ ├── header.tsx
│ │ └── route.tsx
│ ├── app._index/
│ │ ├── route.tsx
│ │ └── stats.tsx
│ ├── app.projects/
│ │ ├── get-projects.server.tsx
│ │ ├── project-buttons.tsx
│ │ ├── project-card.tsx
│ │ └── route.tsx
│ ├── app/
│ │ ├── footer.tsx
│ │ ├── primary-nav.tsx
│ │ └── route.tsx
│ ├── app_.projects.$id.roadmap/
│ │ ├── chart.tsx
│ │ ├── route.tsx
│ │ └── update-timeline.server.tsx
│ └── contact-us.tsx
└── root.tsx
ほう......
Scaling
Our general recommendation for scale is to make every route a folder and put the modules used exclusively by that route in the folder, then put the shared modules outside of routes folder elsewhere. This has a couple benefits:
Easy to identify shared modules, so tread lightly when changing them
Easy to organize and refactor the modules for a specific route without creating "file organization fatigue" and cluttering up other parts of the app
まあ無理してドットで区切らず、良い感じにディレクトリ作って頑張ってくれやみたいなこと書いてある気がします。
最近のNext.js AppRouter脳で整理してみる
ある程度共感もらえると思うんですが、こんな感じにすると我々見やすいと思います。
(実際に動かしてないのでちゃんと動くかわかりません)
app/
├── root.tsx
└── routes/
├── _landing/
│ ├── footer.tsx
│ ├── header.tsx
│ ├── route.tsx
│ ├── _index/
│ │ ├── route.tsx
│ │ └── scroll-experience.tsx
│ └── about/
│ ├── employee-profile-card.tsx
│ ├── get-employee-data.server.tsx
│ ├── route.tsx
│ └── team-photo.jpg
├── app/
│ ├── footer.tsx
│ ├── primary-nav.tsx
│ ├── route.tsx
│ ├── _index/
│ │ ├── route.tsx
│ │ └── stats.tsx
│ ├── projects/
│ │ ├── get-projects.server.tsx
│ │ ├── project-buttons.tsx
│ │ ├── project-card.tsx
│ │ └── route.tsx
│ └── $id.roadmap/
│ ├── chart.tsx
│ ├── route.tsx
│ └── update-timeline.server.tsx
└── contact-us.tsx
ちなみに/app/app._index/route.tsx
と/app/app/route.tsx
ですが、これは明確に両方に役割があります。
それぞれ、
-
/app/app._index/route.tsx
-
/app
のパスでアクセスされたときに最初に表示されるコンポーネントを提供する場合に使用される。(デフォルト状態でマウントされる)
-
-
/app/app/route.tsx
-
/app
のパスでアクセスされたときの主要のページ。
-
ページの構築とデータフェッチング
コンポーネント実装
Remixでは、ページはReactコンポーネントで構築されます。
これらのコンポーネントは、routesディレクトリ内のファイルに定義され、URLの構造に基づいて自動的にルーティングされます。
コンポーネントは、loaderから提供されるデータを受け取り、それをユーザーに表示するUIを構築します。
loader関数
Remixでは初回SSR時に実行されるデータ取得関数を定義可能です。
loaderはサーバーサイドで実行されるため、APIシークレット等の秘匿情報も含めることが出来ます。
また以下のように記述することで型補完を受けることも可能です。
const data = useLoaderData<typeof loader>();
action関数
Remixにおける、GET以外のメソッドをformから実行した際に呼び出される関数です。
同様にクライアントにはバンドルされないため、セキュアな情報を扱うことが可能です。
エラーハンドリング
今ではNextで同じようなこと出来ますが、ファイルルーティングとしてのエラーバウンダリとそのバブリングはRemixが先進だった気がします。
また、useRouteError Hooksがexportされているため、通信エラー等が発生したときにどのルートからでもエラーのユーザーへの通知が可能です。
JSランタイム
ベースとしてNode。denoもCloudflareも使えるよとのこと。
これは強い。
チュートリアルやってみた
(2024/3時点でのものです)
指示通りに実装を進めた場合、最終的なapp以下のファイル構成は以下のようになります。
├── app/
│ ├── app.css
│ ├── data.ts
│ ├── root.tsx
│ └── routes/
│ ├── _index.tsx
│ ├── contacts.$contactId.destroy.tsx
│ ├── contacts.$contactId.tsx
│ └── contacts.$contactId_.edit.tsx
いくつか気になった箇所を例に挙げつつ、機能の紹介が出来ればと思います。
一覧画面(app/root.tsx)
一覧画面の実装は以下のようなものとなります。
app/root.tsx
import type { LinksFunction } from "@remix-run/node";
import { LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import {
Form,
Links,
LiveReload,
Meta,
NavLink,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
useNavigation,
useSubmit,
} from "@remix-run/react";
import { useEffect, useState } from "react";
import appStylesHref from "./app.css";
import { createEmptyContact, getContacts } from "./data";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: appStylesHref },
];
export const action = async () => {
const contact = await createEmptyContact();
json({ contact });
return redirect(`/contacts/${contact.id}/edit`);
};
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();
const submit = useSubmit();
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has("q");
const [query, setQuery] = useState(q || "");
useEffect(() => {
setQuery(q || "");
}, [q]);
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"
onChange={(event) => submit(event.currentTarget)}
role="search"
>
<input
id="q"
name="q"
aria-label="Search contacts"
placeholder="Search"
className={searching ? "loading" : ""}
type="search"
onChange={(event) => {
const isFirstSearch = q === null;
submit(event.currentTarget, {
replace: !isFirstSearch,
});
}}
value={query}
/>
<div id="search-spinner" aria-hidden hidden={!searching} />
</Form>
<Form method="post">
<button type="submit">New</button>
</Form>
</div>
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
to={`contacts/${contact.id}`}
>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite ? <span>★</span> : null}
</NavLink>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
</div>
<div
className={
navigation.state === "loading" && !searching ? "loading" : ""
}
id="detail"
>
<Outlet />
</div>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
useNavigation Hooks
割と気持ち悪いな〜〜〜とボヤきながら読み進めてきましたが、これは面白い機能だと感じています。
const navigation = useNavigation()
として使っているわけですが、実際にどのように使われているか見てみましょう。
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has("q");
<div
className={
navigation.state === "loading" && !searching ? "loading" : ""
}
id="detail"
>
<Outlet />
</div>
この辺ですかね。
loader関数という形で取得処理をそこにまとめる設計となるため、デフォルトで全体のデータ取得状態の状態管理が出来るわけですね。
idle → loading → idle
また、actionに対しても同様で、stateは以下のような移ろいをします。
idle → submitting → loading → idle
データ取得の状態がとてもシンプルに考えられるので良いなーと感じました。
NavLink
「サイドバーで自分がどこおるかわからんやん?こうしたらええねん。」みたいなこと書いてありました。
<NavLink
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
to={`contacts/${contact.id}`}
>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite ? <span>★</span> : null}
</NavLink>
NavLinkそのもののドキュメント読んでも似たような感じでした。
useSubmit Hooks
「親Formのsubmitイベントを発火させる」関数っぽいですね。
奇妙な。
const submit = useSubmit();
<Form
id="search-form"
onChange={(event) => submit(event.currentTarget)}
role="search"
>
<input
id="q"
name="q"
type="search"
onChange={(event) => {
const isFirstSearch = q === null;
submit(event.currentTarget, {
replace: !isFirstSearch,
});
}}
value={query}
/>
なので、以下のような使用方法も、↑と併用が出来るようです。
<Form method="post">
{/* <button type="submit">New</button> */}
<button onClick={(e) => submit(e.currentTarget)}>New</button>
</Form>
「loader関数」と「action関数」を一つの関数で発火させているわけですね。
珍妙です。
編集画面、削除Routeはあんま言うことない
あぁ......って感じ。
app/routes/contacts.$contacts_id.edit.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData, useNavigate } from "@remix-run/react";
import invariant from "tiny-invariant";
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}`);
};
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>();
const navigate = useNavigate();
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} />
</label>
<p>
<button type="submit">Save</button>
<button onClick={() => navigate(-1)} type="button">
Cancel
</button>
</p>
</Form>
);
}
app/routes/contacts.$contacts_id.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("/");
};
強いて言うなら、後述する予定だった詳細画面から、type="POST"のaction="destroy"で削除Routeのaction関数を呼べるんだなーとくらいでしょうか。
<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>
詳細画面
app/routes/contacts.$contacts_id.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import type { FunctionComponent } from "react";
import invariant from "tiny-invariant";
import { ContactRecord, getContact, updateContact } 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 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",
});
};
export default function Contact() {
const { contact } = useLoaderData<typeof loader>();
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 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>
);
};
useFetcher Hooks
上述したもの以外で、目新しいものとしてはこれかと思います。
<Favorite contact={contact} />
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>
);
};
割と面白いこと書いてありますね。ナビゲーションを発生させずにフォームを送信
出来るんだそうです。
ちなみにこの画面のactionはこれ。
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",
});
};
最後に
数年前に見た時から、だいぶ独自の進化を踏んでいるようで安心しました。
次はSPAモードについて調べてみたいと思っています。