LoginSignup
52
41

Reactベースのフルスタックフレームワーク「Remix」の公式ドキュメントを再度、しっかり読んでみた

Last updated at Posted at 2024-03-13

はじめに

ふぁるです。
フロントエンドを実装するエンジニアとしてなんやかんや生きております。

最近Remixが話題ですね。
数年前に一瞬流行って少しの間名前を聞かなかったフレームワークがどうしたのかしら。と、軽く調べてみましたが、SSR一本の武器で戦っていた彼がSPAという剣を手に取ったようですね。

今晩は、ビールを片手にRemixについて公式ドキュメントを読み込み、重要そうなところがあればまとめてみたり、実践してみようと思います。

最後まで読んでいただければ幸いに存じます。

書くこと、書かないこと

  • 書くこと
    • Remixの基本的な構文
    • Remixのディレクトリ規則
    • チュートリアルの内容
  • 書かないこと
    • SPA, SSRの違い等の基礎的な内容
    • JavaScript, TypeScript, React等の基本的な構文
    • 本記事はデフォルトのSSRモードで記述しますが、SSR -> SPAのマイグレーションは割と楽に出来るよう用意してもらえてそうです。

Remixの基本

ディレクトリ・ファイルの命名ルール

基本の形式

基本のプロジェクトのディレクトリの形式は以下です。

app/
  ├── routes/
  └── root.tsx

app/root.tsxはルートのルート(「発音が同じの言語の方はすみません」みたいなこと書いてあって面白くて引用しました)で、Next.jsNuxt.jsでいうところのトップのlayoutっぽいです。
app/root.tsxについては後述します。)

ファイルを追加する場合、以下のような形となります。

app/
  ├── routes/
  │   ├── _index.tsx
  │   └── about.tsx 
  └── root.tsx

app/routes_index.tsxabout.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 />にルーティングされます。

(チュートリアルの内容は後述します。)

root.tsx
<div style={{ backgroundColor: 'red' }}>
 <Outlet />
</div>

の時、routes以下の全てのページにはbackgroundColor: 'red'が適用されるわけですが、

app/concerts.tsx
 <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-speedoapp/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

・・・と思ったんですが、以下を見るとなんとなく良い感じな気もしてきました。

app/routes/files.$.tsx
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モードについて調べてみたいと思っています。

52
41
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
41