Remix チュートリアルを SPA モードで進めてみた

この記事は [NTTコムウェア AdventCalendar 2024](https://qiita.com/advent-calendar/2024/nttcomware) 7 日目の記事です。

## はじめに

NTTコムウェアの桑畑と申します。
普段は社内プロジェクトの品質向上のための技術支援に取り組んでおり、ここ最近は Remix をフレームワークとして採用した社内向けのツール開発に携わっています。

開発中のツールはブラウザの機能のみで要件を実現でき、バックエンドが特に必要なかったことから、Remix の SPA モードを用いる方針としています。

SPA モードの SPA とは Single Page Application を指し、ページ全体を再読み込みせずに動的にコンテンツを切り替えることでユーザ体験の向上が期待できるウェブアプリケーションの一種です。

https://remix.run/docs/en/main/guides/spa-mode

本記事では Remix のチュートリアルをベースに、Remix アプリケーションを SPA モードで実装する際の留意点などを記載します。

SPA モードは 2024 年 1 月の [Remix v2.5.0](https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v250) から導入された比較的新しい機能であり、Remix 自体も React Rotuer v7 へ合流しようとしている過渡期[^1]のため情報が古くなってしまう可能性もありますが、記録として残しておければと思います。

なお、React や Remix 本体についての詳細は割愛いたしますので、何卒ご容赦ください。

## Remix のチュートリアルをやってみて

私は 2 年ほど前にフロントエンドに関する技術支援を行うチームに所属していたのですが、Remix は当時使用した経験がなく、フロントエンドの技術からも暫く離れていたため、再入門する気持ちで Remix のチュートリアルをひととおり実施したあと開発に着手しようと考えました。

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

Remix のチュートリアルは連絡先管理アプリを題材にしたもので、データの取得や変更から UI/UX の改善まで含まれており非常に充実しているのですが、従来の SSR が前提のつくりとなっています。
そのためチュートリアルのアプリケーションを下地にしつつ実際のアプリケーションを作りこんでいく際、SPA モードで開発しようとする場合は少々読み替えが必要になります。

そこで、SPA モードで実装することを意識しながらチュートリアルを改めて順に追っていき、SPA モード向けに API の変更等が必要になった箇所を記載することにしました。

:::note warn
* 参照した公式ドキュメント等は 2024/11 時点のものです
* 本記事では、Remix v2.15.0、React v18.2.0 を使用しています
:::

## SPA モード対応の手順まとめ

はじめに、次のコマンドでチュートリアル用アプリの原型を作成します。

```sh
npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
```

上記のコマンドを実行後、チュートリアルを SPA モードに対応させながら進めていきます。
追加の手順や修正が必要な項目は次のとおりです。

* `vite.config.ts` への設定追加
* `loader`/`action` を `clientLoader`/`clientAction` に置き換える
* `@remix-run/node` からの import を修正する
* `<HydrateFallback />` を export する

### `vite.config.ts` への設定追加

はじめに、[公式ドキュメント](https://remix.run/docs/en/main/guides/spa-mode)に記載に従って SPA モードを有効化します。
手動で SPA モードに変更する場合、`vite.config.ts` のオプションに `ssr: false` を追加します。

```diff_typescript:vite.config.ts
  remix({
      ignoredRouteFiles: ["**/*.css"],
+     ssr: false,
  }),
```

このオプションで SPA モードが有効になり、SPA モードで使用できない API の呼び出しがあった場合にエラーが表示されるようになりますので、開発の早い段階で上記を設定しておくとよろしいかと思います。
設定後、`npm run dev` 等で開発用サーバを起動して進めていきます。

### `loader`/`action` を `clientLoader`/`clientAction` に置き換える

SPA モードによる違いが明確に現れる箇所が、`loader` によるデータ取得と `action` によるデータ変更になるかと思います。
実際に、アプリケーションを SPA モードにした状態でチュートリアルを進めていくと、[Loading Data](https://remix.run/docs/en/main/start/tutorial#loading-data) の項で次のようなエラーが確認できます。

```
SPA Mode: 1 invalid route export(s) in `root.tsx`: `loader`.
```

SSR が有効な Remix アプリケーションの場合、各コンポーネントがレンダーされる際に `loader` がサーバ側で実行され、必要なデータが取得できます。
しかし、`loader` はサーバ側のみで実行できる関数であるため、ブラウザで動作する SPA モードでは無効となります。

データの取得をブラウザ上で実行したい場合、`clientLoader` という専用の関数に置き換える必要があります。

```diff_tsx:root.tsx
  // loader の代わりに clientLoader を export する
- export const loader = async () => {
+ export const clientLoader = async () => {
    const contacts = await getContacts();
    return json({ contacts });
  };

  export default function App() {
    // 型推論も clientLoader に置き換える
-   const { contacts } = useLoaderData<typeof loader>();
+   const { contacts } = useLoaderData<typeof clientLoader>();
```

`clientLoader` はブラウザで実行される都合上、サーバの環境変数を使用する処理などがある場合には注意が必要となりますが、チュートリアルの範囲では単純な置き換えで問題ないかと思います。

また、チュートリアル上で登場する箇所はもう少々先ですが、フォームの送信などデータの変更を処理する `action` も同様で、ブラウザ上のみで実行される `clientAction` に置き換える必要があります。
こちらもチュートリアルの実装ではいずれも `action` → `clientAction` への単純な置き換えで動作することを確認しました。

### `@remix-run/node` からの import を修正する

前述の `clientLoader` への置き換えを実施した後も、タブが「Loading...」となったまま画面に何も表示されず、開発者ツールで確認すると次のエラーが出力されている状態かと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938963/8505406c-d48c-dc68-00cc-e0cb1e393838.png)

```
Uncaught ReferenceError: process is not defined
```

エラーの発生元は `application/json` のレスポンスを生成する `json()` で、こちらは Node.js のユーティリティやポリフィルを提供するパッケージである `@remix-run/node` から import されています。
`process` のように Node.js にしか存在しないオブジェクトが使用されているため、ブラウザで動作するような処理に置き換える必要があります。

単純な解決方法としては、`json()` は `@remix-run/react` にも定義されているため、import を次のように変更することが挙げられます。

```tsx
import { json } from "@remix-run/react";  // エラーは解消するが非推奨
```

ただし、パッケージ内の注釈を確認するとこちらの `json()` メソッドも非推奨とされていることがわかります。

> @deprecated This utility is deprecated in favor of opting into Single Fetch
> via `future.v3_singleFetch` and returning raw objects.  This method will be
> removed in React Router v7.  If you need to return a JSON Response, you can
> use `Response.json()`.

引用内にある [Single Fetch](https://remix.run/docs/en/main/guides/single-fetch) は、リクエストを各 `loader` ごとに実行するのではなく、並行して複数のリクエストを実行する戦略を指しています。

今後 React Router v7 では Single Fetch がデフォルトの挙動となり、Single Fetch ではオブジェクトを JSON にシリアライズせずにそのまま返せるため、`json()` は今後削除される方針になったと考えられます。
本記事で使用している Remix v2 系においては、Single Fetch はフィーチャーフラグ（`v3_singleFetch`）として提供されていますが、今回はチュートリアルに合わせて Single Fetch は有効化せずに進めました。

`loader` からのレスポンスを JSON で返す必要がある場合には `Response.json()` が利用可能との記載があるため、今回は `@remix-run/react` の `json()` メソッドは用いずに、そちらに従って修正しています。

```diff_tsx:root.tsx
  export const clientLoader = async () => {
    const contacts = await getContacts();
    // return json({ contact }); は非推奨
-   return json({ contacts });
+   return Response.json({ contacts });
  };
```

上記の修正により、エラーが解消し正常にコンポーネントがレンダーされることを確認できるかと思います。

`@remix-run/node` からの import で他に注意が必要なものとして、`loader` や `action` に渡す引数の型を定義する `ActionFunctionArgs` や `LoaderFunctionArgs` も挙げられます。
`clientLoader` および `clientAction` への修正と併せて、`@remix-run/react` から `ClientLoaderFunctionArgs` や `ClientActionFunctionArgs` を import する形に随時修正します。

```tsx
import { ClientLoaderFunctionArgs, ClientActionFunctionArgs } from "@remix-run/react";
```

### `<HydrateFallback />` を export する

前述の 2 点の読み替えを全体を通して行うことで、連絡先管理アプリとしては正常に SPA モードで動作するようになりますが、開発者ツールの「Console」タブには次のようなメッセージが残っています。

> 💿 Hey developer 👋. You can provide a way better UX than this when your app is loading JS modules and/or running `clientLoader` functions. Check out https://remix.run/route/hydrate-fallback for more information.

上記のメッセージは、`<HydrateFallback />` を用いた UX 改善を提案するメッセージです。
[公式ドキュメント](https://remix.run/docs/en/main/route/hydrate-fallback)を参照すると、`<HydrateFallback />` は `clientLoader` が完了するまで自前で定義したフォールバックをレンダーする役割を持っており、データ取得中であることをユーザに示すことができます。

この提案を踏まえて、アプリケーションに `<HydrateFallback />` を導入しようと思います。
その際、SPA モードにおいてコンポーネントが表示される `index.html` は `<HydrateFallback />` から生成されるとの記載に着目します[^2]。
`index.html` を正しく生成させるため、自前で export する `<HydrateFallback />` 内に `<html>` タグ等を再度記述する必要がありますが、`<App />` のようなルートコンポーネント内にも `<html>` タグが存在するためやや長い記述になりがちです。

そこで、[Remix v2.7.0](https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v270) で導入された [Layout Export](https://remix.run/docs/en/dev/file-conventions/root#layout-export) を使用することで、`<App />` 等のルートコンポーネントと `<HydrateFallback />`（必要に応じて `<ErrorBoundary />` も）で共通するタグやレイアウトをまとめることができます。

また、Layout Export を使用することで、Remix が `<HydrateFallback />` からルートコンポーネント等へ表示を切り替える際に発生しうる FOUC (Flash of unstyled content: スタイルが適用されていない状態のページが一瞬表示される事象) を防ぐ効果もあるとされているため、UX 改善の効果も見込めると考えられます。

```tsx
// Layout の export を新規に追加
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

// <html>, <body> 等の構造を Layout へ切り出し
export default function App() {
  const { contacts } = useLoaderData<typeof clientLoader>();

  return (
    <>
      <div id="sidebar">
      {/* ...省略... */}
      </div>
      <div id="detail">
        <Outlet />
      </div>
    </>
  );
}

// clientLoader が完了するまで表示されるフォールバック
export function HydrateFallback() {
  return <p>Loading...</p>;
}
```

上記のように変更することで、データの読み込みが完了するまでの間に「Loading...」という文字列が表示されるようになりました。

## おわりに

本記事では、Remix のチュートリアルを SPA モードに対応させる際の注意点などをご紹介しました。
SPA モード特有の注意点はありますが、ブラウザで動作するようにコードを記述すればよい、という考え方もできるのはシンプルで取っつきやすいと感じました。

冒頭で述べたとおり、SPA モードが比較的新しい機能であることや、Remix 本体が React Router v7 へ合流していく状況も踏まえると、今後はまた別のお作法に従う必要が出てきたり、それに合わせてより充実した移行ガイドやチュートリアルが作成されたりする可能性もあります。

SPA モードは静的ホスティングのみで完結するサービスや、既存のバックエンドを活用しつつ画面を刷新する開発などの際の良い選択肢かと思いますので、今後も動向を追っていきたいと思います。

※記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。


[^1]: https://remix.run/blog/react-router-v7
[^2]: https://remix.run/docs/en/main/guides/spa-mode#hydrating-a-div-instead-of-the-full-document