CloudFrontとS3の静的ホスティングが要件だったので、Next.jsのApp Routerを選び、Server Actionsは使いませんでした。フレームワーク選びとは、そういう取捨選択の連続です。
登壇で話した内容を、ブログとして組み直しました。プロジェクトの土台をどう選ぶかと、useEffect を題材に読みやすいReactコードをどう考えるかをまとめます。
プロジェクトをどう始めるか
Reactプロジェクトは、ViteやParcelなどのビルドツールから組み立てることも、Next.jsやReact Routerなどのフレームワークから始めることもできます。
現在のReact公式ドキュメントは、新しいアプリやWebサイトにはフレームワークから始めることを勧めています。一方、既存サイトへReactを加える場合、学習目的の場合、既存フレームワークが要件に合わない場合には、ビルドツールから始める選択肢も案内しています。
ルーティング、データ取得、コード分割、レンダリング戦略を、アプリが成長してから個別に組み合わせるのは簡単ではありません。要件に合うフレームワークなら、周辺ツールを含めた統合を任せられます。ただし「公式が勧めるから」ではなく、プロダクトの配信方法、必要なサーバー機能、運用するチームに合うかを確認して選びます。
2024年にApp Routerを選んだ理由
登壇で扱ったプロジェクトでは、CloudFrontとS3による静的ホスティングが要件でした。Next.jsの静的出力とApp RouterのLayoutsが使いやすく、採用の決め手になりました。現在も静的出力はサポートされていますが、Server Actionsなどサーバーを必要とする機能は利用できません。
このプロジェクトでは、ルーティング、Layouts、ビルドプロセスにApp Routerを使い、データ取得はClient ComponentsからSWRで行いました。非同期Server ComponentsやServer Actionsは、静的出力の要件に合わず、当時は運用知見も少なかったため採用しませんでした。
これは、Next.jsプロジェクトは常にこの構成にすべきという話ではありません。プロダクトに不要な領域とは意識的に距離を取り、必要な機能だけを利用した選択です。
周辺ツールを含めて検証する
2024年当時の構成では、App RouterへMSWを組み込む際に相性の問題がありました。そこで開発時の代替として、既知のキーへデータを返すSWR middlewareを用意しました。
interface MockData {
key: string;
data: unknown;
}
const mockData: MockData[] = [];
export const testMiddleware: Middleware = () => {
return (key): SWRResponse => ({
data: mockData.find((mock) => mock.key === key)?.data,
error: undefined,
mutate: () => Promise.resolve(),
isValidating: false,
isLoading: false,
});
};
これはネットワークをモックしないため、MSWの完全な代替ではありません。それでも、読み込み、エラー、成功といった表示状態を作る用途には十分でした。APIとの結合は別のテストで確認します。
相性の問題は当時のもので、Next.jsとMSWの状況は変わっています。ここから残る教訓は、採用予定のフレームワーク、デプロイ先、テストツール、開発ツールを、小さなPoCで実際に組み合わせて確認することです。
フレームワークを機能の集合として見る
プロジェクトがフレームワークへ求めるものと、Reactへ求めるものを分けて考えると選びやすくなります。このプロジェクトがNext.jsへ求めたのは、ルーティング、Layouts、ビルドプロセスでした。当時Reactへ求めたのは、すべてのCanary機能ではなく、安定したコンポーネントの作法でした。
静的配信かサーバー配信か、セルフホスティングできるか、ナビゲーションやHistory APIをどう扱うか、キャッシュをどこまで利用するか、Server Componentsの設計をチームがどこまで担うか。必要な能力の組み合わせがプロダクトに合うなら、すべての機能を使わなくても、そのフレームワークを選ぶ価値があります。
なぜよいコードを書くのか
開発者は、コードを書くのと同じくらいコードを読みます。Pull Requestをレビューし、既存の振る舞いを追い、変更すべき場所を探します。よいコードは、そうした読み手のために書くものだと考えています。
コメントや焦点の絞られたPull Requestも重要ですが、ここではReactと useEffect に話を絞ります。EffectをReactの外部と同期するための避難ハッチとして扱うと、処理のきっかけを追いやすくなります。
不要なEffectを削除する
イベントハンドラーはきっかけが明確です。この要素をクリックすると、この関数が実行される。一方、Effectはレンダー後に実行され、依存するリアクティブな値が変わるたびに再実行されます。依存値がどこで変わるのかを、コンポーネントツリーの広い範囲から追わなければならない場合もあります。
Effectは悪者ではなく、役割が限定された道具です。この節の内容は、React公式ドキュメントの次のページとあわせて読むと理解しやすくなります。
そのエフェクトは不要かも – ReactThe library for web and native user interfacesEffectの役割
Effectは、コンポーネントをReactの外にあるシステムと同期します。ブラウザイベントの購読はわかりやすい例です。
useEffect(() => {
const handler = () => {
// リサイズへ反応する
};
window.addEventListener("resize", handler);
return () => {
window.removeEventListener("resize", handler);
};
}, []);
イベントリスナーはReactの外部にあり、コンポーネントが必要とする期間だけ存在すべきです。クリーンアップはセットアップと対応します。
チャットサーバーへの接続も同じ形です。
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
送信ボタンを押してメッセージを送る処理はイベントハンドラーに置きます。コンポーネントの存在に合わせて接続を維持する処理はEffectに置きます。
派生値を状態にしない
propsや状態からレンダー中に計算できる値なら、Effectは多くの場合不要です。
const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
fullName は既存の2つの状態から求められます。直接計算すれば、余分な再レンダーと同期ずれの可能性をなくせます。
const [firstName, setFirstName] = useState("Taylor");
const [lastName, setLastName] = useState("Swift");
const fullName = `${firstName} ${lastName}`;
計算が本当に重い場合は、影響する値を依存にしてメモ化します。
const fullName = useMemo(
() => calculateFullName(firstName, lastName),
[firstName, lastName],
);
ライブラリが提供するイベントを使う
SWRで取得した初期値をReact Hook Formへ入れる例を考えます。Effectで各フィールドへコピーすることもできますが、ライブラリのコールバックを使うと処理の順序をより直接的に表せる場合があります。
const methods = useForm<FormSchema>();
useSWR("/wines/reds", fetcher, {
onSuccess: (data) => {
methods.reset(data);
},
});
API通信が成功したらフォームをリセットする、という関係がコードに現れます。要件によってはReact Hook Formの values や defaultValues の方が適切です。後からデータが変わった場合やdirtyなフィールドの扱いが異なるため、Effectを消すこと自体を目的にせず、プロダクトに合う意味を選びます。
useSyncExternalStoreを使う
外部ストアの変化をReactへ同期する場合、useSyncExternalStore を使うと、変更の購読と現在値の読み取りを分けて表現できます。
const subscribe = (callback: VoidFunction) => {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
};
const getSnapshot = () => window.innerWidth;
const getServerSnapshot = () => 0;
const width = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
購読用のEffectと別の状態を組み合わせるより、外部ストアとの関係を正確に伝えられます。
1つのEffectに1つの同期処理を書く
Effectは同期のサイクルを表します。同期を始め、止め、依存するリアクティブな値が変わったら再び始めます。独立した同期処理は別々のEffectへ分け、1つの依存値の変化で無関係な処理まで再実行されないようにします。
依存配列は、望む実行回数へ調整するためのリストではありません。Effect内で使うすべてのリアクティブな値を宣言します。リアクティブである必要のない処理は、コンポーネントの外へ移す、原因となるユーザーイベントへ移す、用途が合えばEffect Eventへ分ける、といった方法を検討します。
useEffectEvent は現在安定版として利用できます。Effectから呼ぶロジックで最新のpropsやstateを読みつつ、それらの変化でEffect自体を再接続したくない場合に役立ちます。実際の依存関係を隠すための仕組みではありません。
さいごに
冒頭の静的ホスティングの話も、Effectの使い方も、行き着く先は同じです。道具を本来の責任に合わせて使うことです。
フレームワークは配信方法、ルーティング、データ、運用の要件から選びます。Effectは、すべての状態変化を取りまとめるためではなく、Reactの外部と同期するために使います。境界が明確なら、アーキテクチャもコードも次の読み手に伝わりやすくなります。