この記事は NTTコムウェア AdventCalendar 2024 7 日目の記事です。
はじめに
NTTコムウェアの桑畑と申します。
普段は社内プロジェクトの品質向上のための技術支援に取り組んでおり、ここ最近は Remix をフレームワークとして採用した社内向けのツール開発に携わっています。
開発中のツールはブラウザの機能のみで要件を実現でき、バックエンドが特に必要なかったことから、Remix の SPA モードを用いる方針としています。
SPA モードの SPA とは Single Page Application を指し、ページ全体を再読み込みせずに動的にコンテンツを切り替えることでユーザ体験の向上が期待できるウェブアプリケーションの一種です。
本記事では Remix のチュートリアルをベースに、Remix アプリケーションを SPA モードで実装する際の留意点などを記載します。
SPA モードは 2024 年 1 月の Remix v2.5.0 から導入された比較的新しい機能であり、Remix 自体も React Rotuer v7 へ合流しようとしている過渡期1のため情報が古くなってしまう可能性もありますが、記録として残しておければと思います。
なお、React や Remix 本体についての詳細は割愛いたしますので、何卒ご容赦ください。
Remix のチュートリアルをやってみて
私は 2 年ほど前にフロントエンドに関する技術支援を行うチームに所属していたのですが、Remix は当時使用した経験がなく、フロントエンドの技術からも暫く離れていたため、再入門する気持ちで Remix のチュートリアルをひととおり実施したあと開発に着手しようと考えました。
Remix のチュートリアルは連絡先管理アプリを題材にしたもので、データの取得や変更から UI/UX の改善まで含まれており非常に充実しているのですが、従来の SSR が前提のつくりとなっています。
そのためチュートリアルのアプリケーションを下地にしつつ実際のアプリケーションを作りこんでいく際、SPA モードで開発しようとする場合は少々読み替えが必要になります。
そこで、SPA モードで実装することを意識しながらチュートリアルを改めて順に追っていき、SPA モード向けに API の変更等が必要になった箇所を記載することにしました。
- 参照した公式ドキュメント等は 2024/11 時点のものです
- 本記事では、Remix v2.15.0、React v18.2.0 を使用しています
SPA モード対応の手順まとめ
はじめに、次のコマンドでチュートリアル用アプリの原型を作成します。
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
への設定追加
はじめに、公式ドキュメントに記載に従って SPA モードを有効化します。
手動で SPA モードに変更する場合、vite.config.ts
のオプションに ssr: false
を追加します。
remix({
ignoredRouteFiles: ["**/*.css"],
+ ssr: false,
}),
このオプションで SPA モードが有効になり、SPA モードで使用できない API の呼び出しがあった場合にエラーが表示されるようになりますので、開発の早い段階で上記を設定しておくとよろしいかと思います。
設定後、npm run dev
等で開発用サーバを起動して進めていきます。
loader
/action
を clientLoader
/clientAction
に置き換える
SPA モードによる違いが明確に現れる箇所が、loader
によるデータ取得と action
によるデータ変更になるかと思います。
実際に、アプリケーションを SPA モードにした状態でチュートリアルを進めていくと、Loading Data の項で次のようなエラーが確認できます。
SPA Mode: 1 invalid route export(s) in `root.tsx`: `loader`.
SSR が有効な Remix アプリケーションの場合、各コンポーネントがレンダーされる際に loader
がサーバ側で実行され、必要なデータが取得できます。
しかし、loader
はサーバ側のみで実行できる関数であるため、ブラウザで動作する SPA モードでは無効となります。
データの取得をブラウザ上で実行したい場合、clientLoader
という専用の関数に置き換える必要があります。
// 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...」となったまま画面に何も表示されず、開発者ツールで確認すると次のエラーが出力されている状態かと思います。
Uncaught ReferenceError: process is not defined
エラーの発生元は application/json
のレスポンスを生成する json()
で、こちらは Node.js のユーティリティやポリフィルを提供するパッケージである @remix-run/node
から import されています。
process
のように Node.js にしか存在しないオブジェクトが使用されているため、ブラウザで動作するような処理に置き換える必要があります。
単純な解決方法としては、json()
は @remix-run/react
にも定義されているため、import を次のように変更することが挙げられます。
import { json } from "@remix-run/react"; // エラーは解消するが非推奨
ただし、パッケージ内の注釈を確認するとこちらの json()
メソッドも非推奨とされていることがわかります。
@deprecated This utility is deprecated in favor of opting into Single Fetch
viafuture.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
useResponse.json()
.
引用内にある 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()
メソッドは用いずに、そちらに従って修正しています。
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 する形に随時修正します。
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 改善を提案するメッセージです。
公式ドキュメントを参照すると、<HydrateFallback />
は clientLoader
が完了するまで自前で定義したフォールバックをレンダーする役割を持っており、データ取得中であることをユーザに示すことができます。
この提案を踏まえて、アプリケーションに <HydrateFallback />
を導入しようと思います。
その際、SPA モードにおいてコンポーネントが表示される index.html
は <HydrateFallback />
から生成されるとの記載に着目します2。
index.html
を正しく生成させるため、自前で export する <HydrateFallback />
内に <html>
タグ等を再度記述する必要がありますが、<App />
のようなルートコンポーネント内にも <html>
タグが存在するためやや長い記述になりがちです。
そこで、Remix v2.7.0 で導入された Layout Export を使用することで、<App />
等のルートコンポーネントと <HydrateFallback />
(必要に応じて <ErrorBoundary />
も)で共通するタグやレイアウトをまとめることができます。
また、Layout Export を使用することで、Remix が <HydrateFallback />
からルートコンポーネント等へ表示を切り替える際に発生しうる FOUC (Flash of unstyled content: スタイルが適用されていない状態のページが一瞬表示される事象) を防ぐ効果もあるとされているため、UX 改善の効果も見込めると考えられます。
// 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 モードは静的ホスティングのみで完結するサービスや、既存のバックエンドを活用しつつ画面を刷新する開発などの際の良い選択肢かと思いますので、今後も動向を追っていきたいと思います。
※記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。