1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1,000行超のZustandストアを「Sliceパターン」で解体し、不要な再描画を根絶するセレクター最適化

1
Posted at

Reactのグローバル状態管理として、Reduxのような冗長なボイラープレートを嫌い、Zustandを採用するプロジェクトは非常に多い。シンプルで扱いやすく、フック1つでどこからでも状態を取り出せる手軽さは圧倒的だ。

しかし、その手軽さに甘えて機能を追加し続けた結果、自作の新しいタブ拡張機能「ZenithTab」のメインストア src/store/useDashboardStore.ts は、気づけば 1,087行の巨大な神ファイル(God File) と化していた(Issue #43)。

ウィジェットの配置、ページ管理、外観テーマ、ドックのショートカット、ゴミ箱、スナップショット、ストレージ同期――あらゆるロジックが1つの create() の中に押し込まれ、コードの可読性は限界に達していた。

それだけではない。もっと静かに進行していたのが、「時計が1秒刻むたび、メモに1文字打つたび、ウィジェットをドラッグするたびに、画面上のすべてのグラスカードと、多言語テキストを表示するほぼ全コンポーネントが無駄に再レンダリングされる」 という再描画の連鎖だった(Issue #41)。

肥大化した巨大ストアを保守性の高いモジュール構造へと生まれ変わらせる「Sliceパターン」の導入と、Zustandのセレクターによる不要な再描画の撲滅テクニックを解説する。


静かな犯人:安易な分割代入による「全再描画」

なぜ、時計の秒が変わっただけで画面全体のコンポーネントが再描画されていたのか。

原因は、いくつかの基盤コンポーネントで無意識に使われていた、次のようなZustandの呼び出し方にあった。

// パフォーマンスを損なうアンチパターン(修正前の GlassCard.tsx)
export const GlassCard = React.forwardRef<HTMLDivElement, GlassCardProps>(
  ({ className, children, variant = 'translucent', ...props }, ref) => {
    // ストア全体から appearance を分割代入で取り出している
    const { appearance } = useDashboardStore();
    // ...
  }
);

// 同じく修正前の i18n.ts
export function useTranslation() {
  const { appearance } = useDashboardStore();
  // ...
}

一見すると何の変哲もないコードに見える。しかし、useDashboardStore() を引数なしで呼び出すと、Zustandは 「このコンポーネントはストア内のあらゆる状態変化を購読している」 と判断する。

ユーザーがメモウィジェットに文字を打ち込んで widgets のステートが更新されたとする。すると、appearance 自体には1ミリも変化がないにもかかわらず、ストアオブジェクト全体の参照が変わったことを受けて、画面上に存在する20個近い GlassCard——すべてのウィジェットはこのカードに包まれている——がすべて道連れで再レンダリングされる。

さらに、多言語テキストを表示する useTranslation() フックはほぼ全コンポーネントから呼ばれている。こちらも同じようにストア全体を購読していたため、ストアが更新されるたびに画面内のほぼ全ラベル・全ボタンが再計算されていたのだ。


セレクター関数によるファイングレインな購読

解決策は明確だ。コンポーネントが必要とする特定のプロパティだけをピンポイントで購読するセレクター関数を必ず渡すことだ。

// 修正後の GlassCard.tsx: 読むフィールドだけを個別に購読
const borderRadius = useDashboardStore((s) => s.appearance.borderRadius);
const glassBlur = useDashboardStore((s) => s.appearance.glassBlur);

// 修正後の i18n.ts: 言語だけを購読
export function useTranslation() {
  const currentLang = useDashboardStore((s) => s.appearance.language || 'auto');
  const t = getTranslation(currentLang);
  // ...
}

セレクター関数を渡すと、Zustandは前回のレンダリング時の返り値と今回の返り値を厳格に比較(デフォルトは === 参照同値比較)する。

メモの編集によって widgets が更新されても、appearance.glassBlur の値が変わっていなければ、Zustandはこのコンポーネントの再レンダリングを完全にスキップする。

「1フィールドにつき1セレクター」を選んだ理由

複数の値を取り出したいとき、オブジェクトリテラルを返すセレクターは毎回新しい参照を生むので、そのままでは再描画が止まらない。

// NG: 毎回新しいオブジェクト参照が作られるため常に再描画される
const { glassBlur, borderRadius } = useDashboardStore((state) => ({
  glassBlur: state.appearance.glassBlur,
  borderRadius: state.appearance.borderRadius,
}));

Zustandが公式に提供している useShallow で浅い比較に切り替えるのが一般的な対処だ。

import { useShallow } from 'zustand/react/shallow';

const { glassBlur, borderRadius } = useDashboardStore(
  useShallow((state) => ({ glassBlur: state.appearance.glassBlur, borderRadius: state.appearance.borderRadius }))
);

ZenithTab ではあえてこれを使わず、フィールドごとにセレクターを1本ずつ書く方針で統一した(SettingsPanel のように十数フィールドを読む場所も同様だ)。理由は2つ。プリミティブ値を返すセレクターは === で正しく比較できるので追加の仕組みがいらないこと、そして「このコンポーネントは何を読んでいるか」が1行1フィールドで grep できることだ。行数は増えるが、レビューで「不要な購読」を見つけやすくなる。

再描画しないことをテストで固定する

「直した」だけでは、次の機能追加でまた const { … } = useDashboardStore() が紛れ込む。そこで React.Profiler を使い、無関係なストア更新でコミットが増えないことを単体テストに固定した(tests/unit/storeSelectors.test.tsx)。

it('GlassCard は appearance.borderRadius / glassBlur 以外のストア更新で再描画しないこと', () => {
  let commits = 0;
  render(
    <Profiler id="card" onRender={() => commits++}>
      <GlassCard>x</GlassCard>
    </Profiler>
  );
  const before = commits;

  act(() => useDashboardStore.setState((s) => ({ widgets: [...s.widgets] })));
  act(() => useDashboardStore.getState().updateAppearance({ dockPosition: 'top' }));
  expect(commits).toBe(before);   // widgets や dockPosition の変更では描画されない

  act(() => useDashboardStore.getState().updateAppearance({ glassBlur: 4 }));
  expect(commits).toBe(before + 1); // 読んでいるフィールドの変更では描画される
});

このセレクターの徹底により、メモのタイピング中に再描画されるコンポーネントは「編集中のメモウィジェット自身」とその周辺だけに絞り込まれた。 React DevTools の「Highlight updates when components render」を有効にして文字を打つと、以前は画面全体のカードが緑色に激しく点滅していたのが、編集中のメモカードだけが静かに光る状態になったことが一目で確認できる。


1,087行のストアを責務ごとに解体する「Sliceパターン」

再レンダリングの火消しと同じ日に進めたのが、肥大化したストアの構造改革だ。

Zustandで中規模〜大規模なアプリを運用する場合のデファクトスタンダードがSliceパターンである。単一のストアインスタンスを維持しながら、状態とアクションの定義をドメイン(責務)ごとの独立したファイルへと分割する手法だ。

ZenithTabでは、ストアを 7つの Slice と、それらが共有する下回りに分割した(src/store/slices/)。

ファイル 責務
uiSlice.ts 編集モード、アプリドロワー、開いている設定モーダル
widgetSlice.ts ウィジェットの追加・削除・設定更新・レイアウト変更
pageSlice.ts 複数ページの追加・切り替え・複製・削除・改名
settingsSlice.ts 壁紙、外観、ドック、キーボードショートカット
trashSlice.ts ゴミ箱からの復元・削除・空にする
snapshotSlice.ts スナップショットの一覧・作成・復元・設定
persistenceSlice.ts initialize()、他タブとの同期、リセット、インポート/エクスポート
helpers.ts ページの読み書き、削除/復元、ゴミ箱と Undo の配管など、複数 Slice が共有する内部処理
types.ts 各 Slice のインターフェースと、それらを交差型で束ねた DashboardState

Sliceの定義方法(TypeScript)

各Sliceは、全体の型 DashboardState を参照する StateCreator として定義する。型の宣言は types.ts に集約し、Slice 側は実装だけを持つ。

// src/store/types.ts
export interface WidgetSlice {
  widgets: DashboardWidget[];
  layouts: ResponsiveLayouts;
  addWidget: (type: WidgetType, customTitle?: string, initialConfig?: Record<string, any>) => void;
  removeWidget: (id: string) => void;
  updateWidgetConfig: (id: string, config: Record<string, any>, title?: string) => void;
  updateLayouts: (currentLayout: Layout[], allLayouts: ResponsiveLayouts) => void;
  // ...
}

export type DashboardState = UiSlice & WidgetSlice & PageSlice & SettingsSlice & TrashSlice & SnapshotSlice & PersistenceSlice;

/** 各 Slice はこの型で書く: set/get はストア全体を見る */
export type DashboardSliceCreator<T> = StateCreator<DashboardState, [], [], T>;
// src/store/slices/widgetSlice.ts
export const createWidgetSlice: DashboardSliceCreator<WidgetSlice> = (set, get) => {
  const { persistPageState, tr, pushUndo, removeWidgetInternal, restoreWidgetInternal, addTrash, removeTrashEntry } =
    createStoreHelpers(set, get);

  return {
    widgets: DEFAULT_WIDGETS,
    layouts: DEFAULT_LAYOUTS,

    addWidget: (type, customTitle, initialConfig) => {
      // 他の Slice が持つ状態(言語設定やアクティブページ)も get() 経由で参照できる
      const { widgets, layouts, appearance } = get();
      // ...
      persistPageState(updatedWidgets, updatedLayouts);
    },

    removeWidget: (id) => {
      const removed = removeWidgetInternal(id);
      if (!removed) return;
      const entry = createTrashedWidget(removed.widget, removed.layouts, removed.pageId, pageName);
      addTrash(entry);
      pushUndo(/* ... */);
    },
    // ...
  };
};

このパターンの美しい点は、あるSlice(例: widgetSlice)の中から、別のSlice(例: settingsSlice)が持っている状態を get().appearance のように透過的に参照できることだ。ストア自体は結合されているため、モジュール間の連携に複雑なイベントバスや中間層を挟む必要がない。

複数の Slice が同じ内部処理(「ページの状態を保存する」「ウィジェットをゴミ箱へ送る」)を必要とするなら、それは helpers.ts の createStoreHelpers(set, get) に置く。Slice 同士が互いを import する循環を作らないための置き場所だ。


メインストアでの結合

分割したSlice群を、メインの useDashboardStore.ts で束ねる。

// src/store/useDashboardStore.ts
import { create } from 'zustand';
import type { DashboardState } from './types';
import { createUiSlice } from './slices/uiSlice';
import { createWidgetSlice } from './slices/widgetSlice';
import { createPageSlice } from './slices/pageSlice';
import { createSettingsSlice } from './slices/settingsSlice';
import { createTrashSlice } from './slices/trashSlice';
import { createSnapshotSlice } from './slices/snapshotSlice';
import { createPersistenceSlice } from './slices/persistenceSlice';

export const useDashboardStore = create<DashboardState>()((...a) => ({
  ...createUiSlice(...a),
  ...createWidgetSlice(...a),
  ...createPageSlice(...a),
  ...createSettingsSlice(...a),
  ...createTrashSlice(...a),
  ...createSnapshotSlice(...a),
  ...createPersistenceSlice(...a),
}));

1,087行あった useDashboardStore.ts は、30行ほどのクリーンな統合ファイルへとスリム化した。

「振る舞いを変えないリファクタリング」の証明

この分割で最も重視したのは、公開インターフェースを1ミリも変えないことだ。状態フィールドの名前、アクションのシグネチャ、コンポーネント側の useDashboardStore((s) => s.xxx) の呼び出し——すべてそのまま。アクションの本体はファイルを移しただけで、中身は書き換えていない。

その証拠として、既存のテストを1行も変更せずに全件パスさせることを完了条件にした。ストアの単体テスト、コンポーネントテスト、Playwright の E2E。テストを直さないと通らないなら、それは振る舞いが変わったということだ。同じ日に SettingsPanel.tsx(1,235行)もタブごとのコンポーネントに分割したが、こちらも同じルールで進めた。

新しいウィジェットの機能を追加したければ widgetSlice.ts だけを開けばよく、設定画面の変更であれば settingsSlice.ts だけを修正すればいい。プルリクエストの差分もドメインごとに明確に分離される。


状態管理ライブラリに振り回されないために

Zustandは極めて柔軟で優れたライブラリだが、その自由度の高さゆえに「設計の規律」を欠くと気づかぬうちに技術的負債を生み出す。

  1. ストアの読み込みには必ずセレクターを渡し、最小限の単位で購読すること——引数なしの useDashboardStore() はレビューで弾く
  2. 「再描画しないこと」は Profiler でテストに固定すること——直した状態を保つのは規律ではなくテストだ
  3. ストアが肥大化し始めたら迷わずSliceパターンでドメイン分割すること——ただし公開インターフェースは変えず、既存テストを触らずに通す

この3つの原則をプロジェクトの共通認識にしておくこと。

それだけで、アプリがどれほど多機能にスケールしても、快適な応答性と高いメンテナンス性を最後まで両立させることができる。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?