T-CREATOR

Zustand を React なしで使う:subscribe と Store API だけで組む最小構成

Zustand を React なしで使う:subscribe と Store API だけで組む最小構成

Zustand を React なしで使うという発想をお持ちでしょうか。実は、この人気の状態管理ライブラリは React コンポーネントがなくても十分に活用できます。subscribe API と Store API だけを使って、軽量で効率的な状態管理システムを構築する方法をご紹介します。

従来の「React + Zustand」という組み合わせから一歩踏み出して、Vanilla JavaScript での新しい可能性を探ってみましょう。きっと驚くほどシンプルで強力な解決策が見つかるはずです。

背景

Zustand の基本概念と設計思想

Zustand は「シンプルで軽量な状態管理」をコンセプトに開発されたライブラリです。名前の由来はドイツ語の「状態」を意味する言葉で、その名の通り状態管理に特化した設計になっています。

多くの開発者は Zustand を React の Context API や Redux の代替として認識していますが、実際にはもっと幅広い用途で活用できるライブラリなのです。

Zustand の核となる仕組みを図で確認してみましょう。

mermaidflowchart TD
    store[Zustand Store] -->|subscribe| listener1[Listener 1]
    store -->|subscribe| listener2[Listener 2]
    store -->|subscribe| listener3[Listener 3]
    action[Action] -->|setState| store
    store -->|状態変更通知| listener1
    store -->|状態変更通知| listener2
    store -->|状態変更通知| listener3

この図が示すように、Zustand の本質は「状態の変更を監視して、変更時に登録された関数を実行する」という Observer パターンにあります。React コンポーネントは実際にはこの Listener の一種にすぎません。

React に依存しない State Manager としての可能性

Zustand の内部アーキテクチャを詳しく見ると、React への依存は思っているよりもずっと少ないことがわかります。

typescript// Zustand の内部構造(簡略化)
interface StoreApi<T> {
  setState: (partial: Partial<T>) => void;
  getState: () => T;
  subscribe: (listener: (state: T) => void) => () => void;
}

このインターフェースには React 固有の機能は一切含まれていません。純粋な JavaScript の関数とオブジェクトだけで構成されています。

React での使用時に利用される useStore フックは、実際にはこの基本的な Store API をラップしているだけなのです。つまり、この Store API を直接使用すれば、React なしでも同等の機能を実現できます。

他の状態管理ライブラリとの比較

React なしでの状態管理において、Zustand がどのような立ち位置にあるかを整理してみましょう。

ライブラリサイズ学習コストReact 非依存度TypeScript 対応
Zustand2.5KB★★★★★★★★★★
Redux8KB+★★★☆☆★★★★☆
MobX17KB+★★★★☆★★★★☆
RxJS40KB+★★★★★★★★★★

Zustand の最大の優位性は、軽量でありながら十分な機能を提供し、なおかつ React への依存が最小限に抑えられている点です。

課題

React なしでの状態管理における一般的な問題

Vanilla JavaScript で状態管理を行う際に、開発者が直面する典型的な課題をいくつか挙げてみましょう。

mermaidflowchart LR
    problems[状態管理の課題] --> complexity[複雑性の増大]
    problems --> sync[状態同期の困難]
    problems --> memory[メモリリーク]
    problems --> debug[デバッグの困難]

    complexity --> code_mess[コードの肥大化]
    complexity --> maintenance[保守性の低下]

    sync --> inconsistent[不整合状態]
    sync --> race_condition[競合状態]

    memory --> event_leak[イベントリスナーの蓄積]
    memory --> reference_leak[参照の残存]

これらの問題は、適切な状態管理パターンを使用することで大幅に軽減できますが、ゼロから実装するには相当な経験と知識が必要になります。

Vanilla JavaScript での状態管理の複雑さ

従来の方法で Vanilla JavaScript での状態管理を実装しようとすると、以下のような課題に直面します。

状態の一元管理が困難

javascript// 問題のあるパターン:グローバル変数での管理
let userState = { name: '', email: '' };
let cartState = { items: [], total: 0 };
let uiState = { loading: false, modal: false };

// 各ファイルで個別に状態を更新
// ファイル A
userState.name = 'John';

// ファイル B
cartState.total += 100;

// どこで何が変更されたか追跡が困難

変更通知の仕組みが複雑

javascript// 手動でのイベント実装
class StateManager {
  constructor() {
    this.state = {};
    this.listeners = [];
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    // 全リスナーに通知(非効率)
    this.listeners.forEach((listener) =>
      listener(this.state)
    );
  }

  subscribe(listener) {
    this.listeners.push(listener);
    // アンサブスクライブ処理を忘れがち
    return () => {
      const index = this.listeners.indexOf(listener);
      this.listeners.splice(index, 1);
    };
  }
}

subscribe パターンの理解の必要性

効率的な状態管理を実現するには、Observer パターン(subscribe パターン)の深い理解が不可欠です。

このパターンでは、以下の概念を正しく理解する必要があります:

  • Publisher(状態を持つオブジェクト):状態の変更を通知する責任を持つ
  • Subscriber(状態を監視するオブジェクト):状態の変更を受け取り、適切に反応する
  • Subscription(購読関係):Publisher と Subscriber を結ぶ関係性

これらの概念を適切に実装するには、メモリ管理、パフォーマンス最適化、エラーハンドリングなど、多くの考慮事項があります。

解決策

Zustand の subscribe API の活用方法

Zustand の subscribe API を使用することで、これらの課題を一気に解決できます。まず、基本的な仕組みを理解しましょう。

typescriptimport { create } from 'zustand';

// Store の作成
interface AppState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useStore = create<AppState>((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
}));

React なしで使用する場合、この useStore から必要な API を抽出します:

typescript// React なしでの基本的な使用方法
const store = useStore;

// 現在の状態を取得
const currentState = store.getState();
console.log(currentState.count); // 0

// 状態変更の監視
const unsubscribe = store.subscribe((state) => {
  console.log('状態が変更されました:', state.count);
});

// 状態の更新
store.getState().increment(); // ログ: "状態が変更されました: 1"

// 監視の解除
unsubscribe();

Store API を使った状態管理パターン

Zustand の Store API は以下の 3 つの主要メソッドを提供します:

mermaidflowchart TB
    store["Store API"] --> getState["getState()"];
    store --> setState["setState()"];
    store --> subscribe["subscribe()"];

    getState --> current["現在の状態を取得"];
    setState --> update["状態を更新"];
    subscribe --> listen["変更を監視"];

    current --> read["読み取り専用操作"];
    update --> action["アクション実行"];
    listen --> reactive["リアクティブ処理"];

getState() - 状態の読み取り

typescript// 任意のタイミングで現在の状態を取得
const getCurrentCount = () => {
  const state = store.getState();
  return state.count;
};

// 条件分岐での状態確認
const isCountEven = () => {
  const { count } = store.getState();
  return count % 2 === 0;
};

setState() - 状態の更新

typescript// 直接的な状態更新(非推奨だが可能)
store.setState({ count: 10 });

// 関数を使った安全な状態更新
store.setState((state) => ({
  count: state.count + 5,
}));

// 部分的な状態更新
store.setState((state) => ({
  ...state,
  lastUpdated: new Date().toISOString(),
}));

subscribe() - 変更の監視

typescript// 基本的な監視
const unsubscribe = store.subscribe((state) => {
  console.log('カウント:', state.count);
});

// 条件付きの処理
const conditionalSubscribe = store.subscribe((state) => {
  if (state.count > 10) {
    console.log('カウントが10を超えました!');
  }
});

// 複数の監視処理
const subscriptions = [
  store.subscribe(updateUI),
  store.subscribe(logStateChange),
  store.subscribe(saveToLocalStorage),
];

// 一括解除
const unsubscribeAll = () => {
  subscriptions.forEach((unsub) => unsub());
};

React コンポーネントなしでの実装アプローチ

React なしでの Zustand 活用には、いくつかの効果的なアプローチがあります。

アプローチ 1: DOM 直接操作

typescript// DOM 要素との直接的な連携
const initializeCounterUI = () => {
  const counterElement = document.getElementById('counter');
  const incrementButton =
    document.getElementById('increment');
  const decrementButton =
    document.getElementById('decrement');

  // 初期表示
  counterElement.textContent = store
    .getState()
    .count.toString();

  // 状態変更の監視
  store.subscribe((state) => {
    counterElement.textContent = state.count.toString();
  });

  // イベントハンドラー
  incrementButton.addEventListener('click', () => {
    store.getState().increment();
  });

  decrementButton.addEventListener('click', () => {
    store.getState().decrement();
  });
};

アプローチ 2: カスタムレンダー関数

typescript// 再利用可能なレンダー関数の作成
interface RenderFunction {
  (state: AppState): void;
}

const createRenderer = (
  selector: string
): RenderFunction => {
  const element = document.querySelector(selector);
  if (!element)
    throw new Error(`Element not found: ${selector}`);

  return (state: AppState) => {
    element.innerHTML = `
      <div class="counter">
        <span class="count">${state.count}</span>
        <button id="inc">+</button>
        <button id="dec">-</button>
      </div>
    `;

    // イベント再バインド
    element
      .querySelector('#inc')
      ?.addEventListener('click', state.increment);
    element
      .querySelector('#dec')
      ?.addEventListener('click', state.decrement);
  };
};

// 使用例
const renderCounter = createRenderer('#counter-container');
store.subscribe(renderCounter);
renderCounter(store.getState()); // 初期レンダー

具体例

最小構成での Store 作成

実際に動作する最小限の例から始めてみましょう。

typescript// 1. 必要なライブラリのインポート
import { create } from 'zustand';

// 2. 状態の型定義
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}
typescript// 3. Store の作成
const useCounterStore = create<CounterState>(
  (set, get) => ({
    count: 0,
    increment: () =>
      set((state) => ({ count: state.count + 1 })),
    decrement: () =>
      set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
  })
);
typescript// 4. React なしでの基本的な使用
const counterStore = useCounterStore;

// 現在の状態を確認
console.log(counterStore.getState().count); // 0

// アクションの実行
counterStore.getState().increment();
console.log(counterStore.getState().count); // 1

counterStore.getState().decrement();
console.log(counterStore.getState().count); // 0

この例では、わずか数行のコードで完全に機能する状態管理システムが構築できることがわかります。

subscribe を使った状態変更の監視

次に、状態の変更を監視して自動的に処理を実行する仕組みを実装してみましょう。

typescript// 基本的な監視の実装
const unsubscribe = counterStore.subscribe((state) => {
  console.log(`カウントが ${state.count} に変更されました`);
});

// テスト実行
counterStore.getState().increment(); // ログ: "カウントが 1 に変更されました"
counterStore.getState().increment(); // ログ: "カウントが 2 に変更されました"
counterStore.getState().reset(); // ログ: "カウントが 0 に変更されました"
typescript// より実用的な監視例:ローカルストレージへの自動保存
const setupAutoSave = () => {
  const unsubscribe = counterStore.subscribe((state) => {
    localStorage.setItem(
      'counter',
      JSON.stringify({
        count: state.count,
        timestamp: new Date().toISOString(),
      })
    );
  });

  // 初期化時にローカルストレージから復元
  const saved = localStorage.getItem('counter');
  if (saved) {
    const { count } = JSON.parse(saved);
    counterStore.setState({ count });
  }

  return unsubscribe;
};

const stopAutoSave = setupAutoSave();
typescript// 条件付き処理の実装
const setupNotifications = () => {
  return counterStore.subscribe((state) => {
    if (state.count === 10) {
      console.log('🎉 カウントが10に到達しました!');
    } else if (state.count < 0) {
      console.log('⚠️ カウントがマイナスになりました');
    } else if (state.count > 100) {
      console.log('🔥 カウントが100を超えました!');
    }
  });
};

const stopNotifications = setupNotifications();

実際の DOM 操作との連携例

最後に、実際の Web アプリケーションで使用できる完全な例を作成してみましょう。

HTML 構造:

html<!DOCTYPE html>
<html>
  <head>
    <title>Zustand Without React</title>
    <style>
      .counter-app {
        max-width: 400px;
        margin: 50px auto;
        padding: 20px;
        text-align: center;
        border: 1px solid #ddd;
        border-radius: 8px;
      }

      .count-display {
        font-size: 48px;
        font-weight: bold;
        margin: 20px 0;
        color: #333;
      }

      .button-group {
        display: flex;
        gap: 10px;
        justify-content: center;
      }

      button {
        padding: 10px 20px;
        font-size: 16px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        transition: background-color 0.2s;
      }

      .primary {
        background-color: #007bff;
        color: white;
      }
      .danger {
        background-color: #dc3545;
        color: white;
      }
      .secondary {
        background-color: #6c757d;
        color: white;
      }
    </style>
  </head>
  <body>
    <div class="counter-app">
      <h1>Zustand Counter App</h1>
      <div id="count-display" class="count-display">0</div>
      <div class="button-group">
        <button id="decrement-btn" class="danger">-</button>
        <button id="reset-btn" class="secondary">
          Reset
        </button>
        <button id="increment-btn" class="primary">
          +
        </button>
      </div>
      <div
        id="status"
        style="margin-top: 20px; font-size: 14px; color: #666;"
      ></div>
    </div>

    <script type="module" src="app.js"></script>
  </body>
</html>

JavaScript 実装(app.js):

typescriptimport { create } from 'zustand';

// Store の定義
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

const store = useCounterStore;
typescript// DOM 要素の取得
const elements = {
  countDisplay: document.getElementById('count-display'),
  incrementBtn: document.getElementById('increment-btn'),
  decrementBtn: document.getElementById('decrement-btn'),
  resetBtn: document.getElementById('reset-btn'),
  status: document.getElementById('status'),
} as const;

// UI の更新関数
const updateUI = (state: CounterState) => {
  elements.countDisplay.textContent =
    state.count.toString();

  // 状態に応じたスタイル変更
  if (state.count > 0) {
    elements.countDisplay.style.color = '#28a745'; //
  } else if (state.count < 0) {
    elements.countDisplay.style.color = '#dc3545'; //
  } else {
    elements.countDisplay.style.color = '#333'; // デフォルト
  }

  // ステータス表示
  if (state.count === 0) {
    elements.status.textContent = '初期状態です';
  } else if (state.count > 0) {
    elements.status.textContent = `${state.count} 回増加しました`;
  } else {
    elements.status.textContent = `${Math.abs(
      state.count
    )} 回減少しました`;
  }
};
typescript// イベントハンドラーの設定
const setupEventHandlers = () => {
  elements.incrementBtn.addEventListener('click', () => {
    store.getState().increment();
  });

  elements.decrementBtn.addEventListener('click', () => {
    store.getState().decrement();
  });

  elements.resetBtn.addEventListener('click', () => {
    store.getState().reset();
  });

  // キーボードショートカット
  document.addEventListener('keydown', (event) => {
    switch (event.key) {
      case 'ArrowUp':
      case '+':
        event.preventDefault();
        store.getState().increment();
        break;
      case 'ArrowDown':
      case '-':
        event.preventDefault();
        store.getState().decrement();
        break;
      case '0':
      case 'r':
        event.preventDefault();
        store.getState().reset();
        break;
    }
  });
};
typescript// アプリケーションの初期化
const initializeApp = () => {
  // 状態変更の監視を開始
  const unsubscribe = store.subscribe(updateUI);

  // イベントハンドラーの設定
  setupEventHandlers();

  // 初期表示
  updateUI(store.getState());

  // 自動保存の設定
  const stopAutoSave = setupAutoSave();

  // クリーンアップ関数を返す
  return () => {
    unsubscribe();
    stopAutoSave();
  };
};

// アプリケーション開始
const cleanup = initializeApp();

// ページアンロード時のクリーンアップ
window.addEventListener('beforeunload', cleanup);

この実装では、以下の機能が含まれています:

  • リアルタイム UI 更新:状態が変更されると即座に画面に反映
  • キーボードショートカット:矢印キーや文字キーでの操作
  • 自動保存:ブラウザを閉じても状態が保持される
  • 視覚的フィードバック:数値に応じた色の変更
  • 適切なクリーンアップ:メモリリークを防ぐ処理

まとめ

React なし Zustand の利点と制限

利点

React なしで Zustand を使用することで、以下のような大きなメリットが得られます:

  1. 軽量性の向上:React の依存関係を排除することで、バンドルサイズを大幅に削減できます(React 本体だけで約 130KB)

  2. 学習コストの低減:React の知識がなくても、JavaScript の基本的な知識だけで状態管理を実装できます

  3. フレームワーク非依存:Vue.js、Angular、Svelte など、どのフレームワークとも組み合わせが可能です

  4. パフォーマンスの最適化:必要な部分だけを更新する仕組みを自由に設計できます

  5. 段階的導入:既存のプロジェクトに少しずつ導入できます

制限事項

一方で、以下のような制限も理解しておく必要があります:

  1. 手動 DOM 操作:React の宣言的 UI の恩恵を受けられず、DOM 操作を手動で記述する必要があります

  2. エコシステムの制約:React 向けのミドルウェアやツールの一部が使用できません

  3. 開発効率:複雑な UI の場合、React と比較して開発に時間がかかる可能性があります

適用可能なユースケース

React なし Zustand が特に威力を発揮するシーンをご紹介します:

最適なユースケース

mermaidflowchart TD
    use_cases[適用ケース] --> simple[シンプルなWebアプリ]
    use_cases --> library[ライブラリ開発]
    use_cases --> migration[既存アプリの移行]
    use_cases --> performance[パフォーマンス重視]

    simple --> landing[ランディングページ]
    simple --> tool[管理ツール]
    simple --> dashboard[ダッシュボード]

    library --> widget[ウィジェット]
    library --> plugin[プラグイン]
    library --> sdk[SDK開発]

    migration --> gradual[段階的移行]
    migration --> prototype[プロトタイプ]
    migration --> experiment[実験的機能]

    performance --> realtime[リアルタイム処理]
    performance --> mobile[モバイル対応]
    performance --> embedded[組み込みシステム]

具体的な適用例

  1. 管理画面やダッシュボード:データの表示と基本的な操作が中心
  2. ランディングページ:軽量性が重要で、複雑な状態管理は不要
  3. ライブラリ開発:フレームワークに依存しない汎用性が求められる
  4. リアルタイムアプリ:WebSocket との連携でデータを即座に反映
  5. モバイル Web アプリ:パフォーマンスとサイズ制約が厳しい環境

React なし Zustand は、「シンプルで効率的な状態管理」を求める多くのプロジェクトにとって、理想的な選択肢となるでしょう。subscribe API と Store API という強力でシンプルな仕組みを理解すれば、きっと新しい開発の可能性が広がるはずです。

関連リンク