【テクニカル・上級編】 Zustandにおけるセレクターによるレンダリング最適化 – React実践ガイド

こんにちは、フロントエンドの深淵を覗く旅人の皆さん。
日々、`useEffect`の依存配列の亡霊にうなされ、不必要な再レンダリングの炎に焼かれていることだろう。

今回は、Reactにおける状態管理のパラダイムシフトを起こしたZustandを取り上げる。その中でも、「セレクターによるレンダリング最適化」という、極限までパフォーマンスを絞り出すための技術的急所について、ブラウザのレンダリングパイプラインやJavaScriptの参照同一性の観点から深く掘り下げていこう。

Reduxがもたらした「巨大な単一ストア」の思想は美しかったが、実務の現場では、コンポーネントが不要な状態の変化まで購読してしまい、無駄な仮想DOMの差分計算にCPUサイクルの大部分を奪われるという悲劇を量産した。Zustandはこのアンチパターンに対し、「必要な部分だけを正確に摘み食い(購読)する」というエレガントな解決策をセレクターパターンによって提供している。

さあ、内部の挙動から実務レベルのアーキテクチャまで、徹底的に解剖していこう。

—

なぜ「丸ごと購読」は悪夢なのか?

まずは、よくある実装のアンチパターンから見ていこう。Zustandのストアから、状態を丸ごとオブジェクトとして取得してしまうコードだ。

// 悪い例:ストア全体を購読している
const { user, theme, notifications } = useAppStore();

一見すると何が悪いのか分かりづらい。しかし、Reactのレンダリング機構の根幹を思い出してほしい。デフォルトで、Zustandの`useStore`フックは、セレクターが渡されない場合、ストア全体のオブジェクトを返す。そして、ストア内のいかなるプロパティが変化しようとも、新しく生成されたオブジェクトの参照を返すことになる。

JavaScriptにおいて、`{ a: 1, b: 2 } === { a: 1, b: 2 }` は `false` である。
つまり、`theme` がダークモードからライトモードに切り替わっただけで、まったく関係のない `user` プロフィールを表示しているだけのコンポーネントまでもが、参照の不一致(Referential Inequality)を検知し、強制的に再レンダリングの憂き目に遭うのだ。これが、大規模アプリケーションにおけるスロースターター、すなわち「死のレンダリング連鎖」の正体である。

セレクターによる「外科的手術」の基本

この無駄な再レンダリングを防ぐ唯一にして最強の武器がセレクター(Selector)だ。
Zustandのフックには、状態の一部のみを切り出す関数を引数として渡すことができる。

import { create } from ‘zustand’;

interface UserState {
user: { id: string; name: string };
theme: ‘light’ | ‘dark’;
updateName: (name: string) => void;
}

const useAppStore = create((set) => ({
user: { id: ‘1’, name: ‘Alice’ },
theme: ‘dark’,
updateName: (name) => set((state) => ({ user: { …state.user, name } })),
}));

// 良い例:ユーザーの名前だけに絞って購読する
const userName = useAppStore((state) => state.user.name);

このアプローチを取ると、Zustandの内部で何が起きるか?
セレクター関数(`(state) => state.user.name`)が返す値(プリミティブな文字列)を前回の値と比較する。値が厳密等価(`===`)であれば、Zustandはコンポーネントの再レンダリングを完全にブロックする。

`theme` が変更されようとも、`userName` さえ変わっていなければ、このコンポーネントのCPUは一ミリも消費されない。これが目指すべき「外科的手術」のような正確な最適化だ。

—

プリミティブ vs オブジェクト:セレクターの陥る罠

しかし、ギークなエンジニアならここで疑問を持つはずだ。
「では、セレクターで複数の値やオブジェクトを切り出したい場合はどうすればいいのか?」

例えば、以下のようなコードを書いたとしよう。

// 危険な例:セレクター内で新しいオブジェクトを作っている
const { id, name } = useAppStore((state) => ({
id: state.user.id,
name: state.user.name,
}));

このコードを書いた瞬間、あなたの最適化の努力は水泡に帰す。
なぜなら、セレクター関数が毎回新しいオブジェクトリテラル `{ id, name }` を生成しているからだ。たとえ中の値が一切変わっていなくても、返されるオブジェクトの参照が毎回異なるため、Zustandは「状態が変化した」と誤認し、容赦なく再レンダリングを引き起こす。

解決策1: `shallow`(浅い比較)の導入

複数の値を安全に購読したい場合、Zustandが提供する `shallow` ユーティリティ(内部的には `shallow equal`)を組み合わせて、オブジェクトのプロパティ単位で値の同値性を担保する必要がある。

import { useShallow } from ‘zustand/react/shallow’;

// 良い例:useShallowを使ってオブジェクトのプロパティを浅く比較する
const { id, name } = useAppStore(
useShallow((state) => ({
id: state.user.id,
name: state.user.name,
}))
);

`useShallow` を挟むことで、オブジェクトの階層が1段の場合に限り、キーと値がすべて一致しているかを判定してくれる。これにより、無駄な再レンダリングの連鎖を断ち切ることができる。

解決策2: カスタムフックへのカプセル化

さらにアーキテクチャを洗練させるなら、コンポーネント側で直接 `useShallow` や複雑なセレクターを書くのではなく、ストアの近く(モジュール内)に専用のカスタムフックとして定義してしまうのが、実務上最も堅牢なアプローチだ。

// ストア定義ファイルなどに閉じ込める
export const useUserProfile = () => {
return useAppStore(
useShallow((state) => ({
id: state.user.id,
name: state.user.name,
}))
);
};

// コンポーネント側は極めてクリーンに保つ
const Component = () => {
const { id, name } = useUserProfile();
// …
};

このように設計することで、状態の構造が将来的に変更された際も、修正箇所をストアの定義周辺に局所化できる。コンポーネント層を「ドメインの知識」から解放し、純粋なビューの表現に専念させることが可能になる。

—

アーキテクトとしての総括

Reactアプリケーションのパフォーマンスチューニングにおいて、ボトルネックの大部分は「不必要な再レンダリング」に起因する。特に状態管理ライブラリの選定と、その購読メカニズムの理解は、アプリケーションがスケールした際の命運を分ける。

Zustandのセレクターパターンは、単なる「便利な機能」ではない。
JavaScriptの参照のセマンティクスと、Reactのレンダーパイプラインの特性を深く理解した上で適用すべき、極めてエンジニアリング的なアプローチだ。

「ストア全体を雑にインポートする」という悪癖を捨て去り、セレクターと `useShallow` を駆使して、ブラウザのメインスレッドに優しく、かつミリ秒単位で応答する堅牢なフロントエンドアーキテクチャを構築してほしい。

健闘を祈る。

コメント

タイトルとURLをコピーしました