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

# はじめに
[ふぁる](https://twitter.com/fal_engineer)です。
フロントエンドを実装するエンジニアとしてなんやかんや生きております。

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

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

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

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

# Remixの基本
## ディレクトリ・ファイルの命名ルール
https://remix.run/docs/en/main/file-conventions/routes#route-file-naming

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

```
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` |

### ドット区切り文字

https://remix.run/docs/en/main/file-conventions/routes#dot-delimiters

 さらに以下のようにした場合、

```
 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` |

こうなります。
`.`がネストを作成するようです。
まだ許せます。

### 動的セグメント

https://remix.run/docs/en/main/file-conventions/routes#dynamic-segments

動的セグメント（`/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）

https://remix.run/docs/en/main/file-conventions/routes#nested-routes

```
 app/
  ├── routes/
  │   ├── _index.tsx
  │   ├── about.tsx
  │   ├── concerts._index.tsx
  │   ├── concerts.$city.tsx
  │   ├── concerts.trending.tsx
  │   └── concerts.tsx
  └── root.tsx
```

 個人的にはこの思想は好きです。
 （幾ら公式ドキュメントでも、このレベルはもうコロケーションしちまおうぜ、とは思うんですが）

 具体的な解説ですが、Remixでは子コンポーネントは`<Outlet />`にルーティングされます。

 https://remix.run/docs/en/main/start/tutorial#nested-routes-and-outlets

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

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

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

```jsx:app/concerts.tsx
 <div style={{ color: 'blue' }}> 
  <Outlet />
 </div>
```

となっている時、`concerts.xxx.tsx`のページは全て`backgroundColor: 'red'`、`color: 'blue'`で表示が行われます。

```jsx
<Root>
  <Conserts>
    <(concerts.xxx) />
  </Conserts>
</Root>
```

こうなるわけですね。
最近レイアウトをネストさせるの流行ってる気がします。気のせいですかね？

### レイアウトをネストさせないネスト

https://remix.run/docs/en/main/file-conventions/routes#nested-urls-without-layout-nesting

```
 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の`()`によるグルーピングはとてもいい感じ（最初拒否感凄かった）なのでもしかしたら好きになれるかもしれません。

### 任意セグメント

https://remix.run/docs/en/main/file-conventions/routes#optional-segments

```
 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`とかにでもリダイレクトさせた方がええで
みたいな感じですかね。

（無理やりリダイレクトさせてまで使いたくないなぁこの機能......）

### スプラットルート

https://remix.run/docs/en/main/file-conventions/routes#splat-routes

```
 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

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

```ts:app/routes/files.$.tsx
export async function loader({
  params,
}: LoaderFunctionArgs) {
  const filePath = params["*"];
  return fake.getFileInfo(filePath);
}
```

`/files/`から始まるURLでサーバー上のファイルパスを指定したとき、そのファイルの情報が取れるとか（他にも活用事例はあると思いますが）考えるとこいつは有用な気がしました。

### 特殊文字のエスケープ

https://remix.run/docs/en/main/file-conventions/routes#escaping-special-characters

面白いと思います。
特に触れません。

### ディレクトリの整理

https://remix.run/docs/en/main/file-conventions/routes#folders-for-organization

```
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

まあ無理してドットで区切らず、良い感じにディレクトリ作って頑張ってくれやみたいなこと書いてある気がします。

<details><summary>最近のNext.js AppRouter脳で整理してみる</summary>

ある程度共感もらえると思うんですが、こんな感じにすると我々見やすいと思います。
（実際に動かしてないのでちゃんと動くかわかりません）

```
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
```
</details>

ちなみに`/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シークレット等の秘匿情報も含めることが出来ます。

https://remix.run/docs/en/main/route/loader

また以下のように記述することで型補完を受けることも可能です。

```ts
const data = useLoaderData<typeof loader>();
```

### action関数
Remixにおける、GET以外のメソッドをformから実行した際に呼び出される関数です。
同様にクライアントにはバンドルされないため、セキュアな情報を扱うことが可能です。

## エラーハンドリング

https://remix.run/docs/en/main/guides/errors#error-handling

今ではNextで同じようなこと出来ますが、ファイルルーティングとしてのエラーバウンダリとそのバブリングはRemixが先進だった気がします。
また、useRouteError Hooksがexportされているため、通信エラー等が発生したときにどのルートからでもエラーのユーザーへの通知が可能です。

## JSランタイム

https://remix.run/docs/en/main/discussion/runtimes

ベースとしてNode。denoもCloudflareも使えるよとのこと。
これは強い。

# チュートリアルやってみた

（2024/3時点でのものです）

https://remix.run/docs/en/main/start/tutorial

https://github.com/FAL-coffee/remix-tutorial

指示通りに実装を進めた場合、最終的な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)

一覧画面の実装は以下のようなものとなります。

<details><summary> app/root.tsx </summary>

```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>
  );
}

```

</details>

### useNavigation Hooks

割と気持ち悪いな〜〜〜とボヤきながら読み進めてきましたが、これは面白い機能だと感じています。

https://remix.run/docs/en/main/hooks/use-navigation

`const navigation = useNavigation()`として使っているわけですが、実際にどのように使われているか見てみましょう。

```tsx
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has("q");
```

```tsx
        <div
          className={
            navigation.state === "loading" && !searching ? "loading" : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
```

この辺ですかね。
loader関数という形で取得処理をそこにまとめる設計となるため、デフォルトで全体のデータ取得状態の状態管理が出来るわけですね。
`idle → loading → idle`
また、actionに対しても同様で、stateは以下のような移ろいをします。
`idle → submitting → loading → idle`

データ取得の状態がとてもシンプルに考えられるので良いなーと感じました。

https://remix.run/docs/en/main/start/tutorial#global-pending-ui

### NavLink

「サイドバーで自分がどこおるかわからんやん？こうしたらええねん。」みたいなこと書いてありました。

https://remix.run/docs/en/main/start/tutorial#active-link-styling

```tsx
                    <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そのもののドキュメント読んでも似たような感じでした。

https://remix.run/docs/en/main/components/nav-link#navlink

### useSubmit Hooks

https://remix.run/docs/en/main/start/tutorial#submitting-forms-onchange

「親Formのsubmitイベントを発火させる」関数っぽいですね。
奇妙な。

```tsx
  const submit = useSubmit();
```

```tsx
            <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}
              />
```

なので、以下のような使用方法も、↑と併用が出来るようです。

```tsx
            <Form method="post">
              {/* <button type="submit">New</button> */}
              <button onClick={(e) => submit(e.currentTarget)}>New</button>
            </Form>
```

**「loader関数」と「action関数」を一つの関数で発火させている**わけですね。
珍妙です。

https://remix.run/docs/en/main/hooks/use-submit#usesubmit

## 編集画面、削除Routeはあんま言うことない 

あぁ......って感じ。

<details><summary>app/routes/contacts.$contacts_id.edit.tsx</summary>

```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>
  );
}

```

</details>

<details><summary>app/routes/contacts.$contacts_id.destroy.tsx</summary>

```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("/");
};

```

</details>

強いて言うなら、後述する予定だった詳細画面から、type="POST"のaction="destroy"で削除Routeのaction関数を呼べるんだなーとくらいでしょうか。

```tsx
          <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>
```

## 詳細画面

<details><summary>app/routes/contacts.$contacts_id.tsx</summary>

```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>
  );
};
```

</details>

### useFetcher Hooks
上述したもの以外で、目新しいものとしてはこれかと思います。

```tsx
          <Favorite contact={contact} />
```

```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 ? "Remove from favorites" : "Add to favorites"}
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
};
```

割と面白いこと書いてありますね。`ナビゲーションを発生させずにフォームを送信`出来るんだそうです。

https://remix.run/docs/en/main/start/tutorial#forms-without-navigation

ちなみにこの画面のactionはこれ。

```tsx
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モードについて調べてみたいと思っています。

https://remix.run/docs/en/main/future/spa-mode#what-is-spa-mode