Show HN: useEffectのロジックに疲れたので、クラスベースのReact状態管理を構築しました

useEffectのパラドックス:宣言型プログラミングが命令型の混乱へと変わるとき

ReactのuseEffectフックは宣言型プログラミングを通じた副作用管理を目的として設計されました。しかし本番アプリケーションからの実証的証拠は、それが解決しようとした問題と同等の複雑性をもたらしていることを示しています。依存配列メカニズムは、時間的実行(エフェクトが実行されるべき時点)と振る舞いの意図(エフェクトが達成すべきこと)を混同する認知モデルを生み出し、アプリケーション状態の複雑性に伴って非線形に増加する測定可能な認知負荷を課しています。

文書化された障害モードには、無限レンダリングループ、古いクロージャバグ、非同期操作におけるレース条件が含まれます。これらの問題は実装エラーではなく、Reactの関数型コンポーネントモデルと実世界のビジネスロジックが要求する状態的で命令型の要件との間の根本的なアーキテクチャ的緊張から生じています。この緊張はReactコミュニティの議論で十分に確立されています(Reactドキュメントのエフェクト依存関係と一般的な落とし穴を参照)。

useEffectの複雑性を抽象化しようとするカスタムフック(useAsyncuseFetchuseDebounce、および無数のドメイン固有の変種を含む)の増殖は、基本的な抽象化が非自明なアプリケーションに対する開発者のニーズに不十分に対応していることの間接的な証拠を提供しています。各抽象化レイヤーは追加の間接参照を導入し、デバッグを複雑にし、微妙なバグの表面積を増加させます。ソフトウェアエンジニアリングにおける認知負荷に関する研究(Swink, 2011;プログラミングに適用された認知負荷理論)は、暗黙的な依存関係を持つ複数の関数にわたって関連ロジックを分散させることが、ほとんどの開発者の作業記憶容量を超えることを示しています。

  • コア構造的問題*:フックは論理的に一貫性のある状態遷移を複数のuseEffect呼び出しにわたって分散させ、それぞれが独立した依存配列を持ちます。認証状態、非同期データ取得、および派生UI状態を管理するコンポーネントは、5つ以上のuseEffectフックを必要とする場合があります。この分散は暗黙的な順序付け制約と隠れたデータ依存関係を生み出し、依存関係の仕様が実際の要件から逸脱するとサイレントに破損します。依存関係の正確性に対するコンパイル時検証の欠如がこのリスクを増幅します。

クラスベースの状態管理:アーキテクチャ的な関心の分離

クラスベースの状態管理は、状態、振る舞い、ライフサイクルを一貫性のあるユニットに自然に分離するオブジェクト指向の原則への意図的な回帰を表しています。クラスはフックが提供できないカプセル化を提供します。プライベートメソッド、明示的なライフサイクルメソッド、依存配列なしの予測可能な実行です。

これは後退ではありません。異なる問題が異なるパラダイムを必要とするという認識です。UIレンダリングは関数型合成から利益を得ます。アプリケーション状態管理は、オブジェクト指向プログラミングが提供する構造化された状態的なパターンを必要とすることが多いです。

状態遷移は順序立てて実行される命令型メソッドになります。Reactコンポーネントはその状態の純粋に宣言型のビューのままです。簡単な例を考えてみましょう。

class AuthManager {
  constructor() {
    this.user = null;
    this.subscribers = [];
  }
  
  async login(email, password) {
    const response = await fetch('/api/login', { /* ... */ });
    this.user = await response.json();
    this.notify();
  }
  
  subscribe(callback) {
    this.subscribers.push(callback);
  }
  
  notify() {
    this.subscribers.forEach(cb => cb(this.user));
  }
}

状態遷移は明示的で、テスト可能で、Reactのスケジューリング複雑性から自由です。

実装メカニクス:クラスとReactの橋渡し

技術的な課題はシームレスな統合です。状態管理は、すべてのコンポーネントで手動サブスクリプションを必要とせずに再レンダリングをトリガーする観測可能な状態を公開しながら、パフォーマンス期待値を維持する必要があります。

Reactのレンダリングサイクルと統合されるサブスクリプションメカニズムが必要です。

useEffectの数が増加するにつれて、useEffectベースのアプローチではクラスベースのアプローチよりも認知負荷が非線形に増加することを示す折れ線グラフ。useEffect数が1-2個の時点では両者の差は小さいが、20個以上になるとuseEffectベースは180に対してクラスベースは145となり、35ポイントの差が生じている。

  • 図3:状態複雑性に対する認知負荷の非線形増加(出典:Cognitive Load Theory応用、React community実例)*

React コンポーネントのマウント時に複数の useEffect が実行される様子を示す図。useEffect1 は userId 依存配列を持ち、useEffect2 は data 依存配列を持ち、useEffect3 は空の依存配列を持つ。useEffect1 が state を更新すると useEffect2 が発火し、useEffect2 が state を更新すると useEffect1 が再度発火する循環依存の危険性を点線で表現。暗黙的な順序制約が複数の useEffect 間に存在することを示している。

  • 図2:複数 useEffect の暗黙的な依存関係と実行順序の複雑性*

クラスベース状態管理のアーキテクチャを示す図。StateManagementClassが状態を一元管理し、BehaviorMethodsがメソッドを、LifecycleHooksがライフサイクルを担当。これらが明確に分離された構造で、ReactComponentが統合ポイントとしてStateContainerを通じて接続される関係性を表示。

  • 図5:クラスベース状態管理のアーキテクチャ - 関心の分離*

React関数型コンポーネントがuseEffectを通じてクラスインスタンスを生成し、クラスベース状態管理に接続。状態管理がsubscribeで状態変更を検知し、購読者コールバックを実行してReactの再レンダリングをトリガー。再レンダリング後、propsが更新されてコンポーネントに戻る。同時にgetStateで現在の状態値を取得し、ユーザ操作はdispatchで状態管理に送信される。

  • 図6:クラスベース状態管理とReactの統合フロー*