•12 min read

Reduxの置き換え: React ContextとuseReducerによる高度な状態管理

Reduxの置き換え: React ContextとuseReducerによる高度な状態管理

長年、ReduxはReactの状態管理において揺るぎない王者でした。予測可能なステートコンテナ、堅牢なミドルウェアエコシステム、そして開発者を魅了するタイムトラベルデバッガを提供していました。しかし、Reactが進化するにつれて、Reduxのボイラープレートと複雑さは、多くのアプリケーションにとって恩恵というよりも負担に感じられるようになりました。

Hooks、特にuseContextとuseReducerの導入により、Reactは多くのユースケースでReduxに取って代わる可能性のある組み込みのプリミティブを提供しました。Robin Wieruch氏のような多くのチュートリアルは、これらの概念を導入する上で素晴らしい仕事をしてきましたが、スケールする際に発生する重大なパフォーマンスのボトルネック、アーキテクチャの課題、およびミドルウェアの要件に対処するところまでは至らないことがよくあります。

この包括的で高度に技術的なガイドでは、基本を超えて掘り下げていきます。ContextとuseReducerを使用して、レンダリングパフォーマンス、ステートスライシング、カスタムミドルウェアパターン、そして開発者体験(DX)とランタイム効率の両方でReduxに匹敵する方法に焦点を当て、最適化されたスケーラブルな状態管理ソリューションを構築します。

Audio Briefing
0:00 / 0:00

素朴なContextとuseReducerの問題点

Reduxを置き換える最も一般的なアプローチは、アプリケーション全体(または大きなサブセクション)を単一のContext Providerでラップし、ステートオブジェクトとdispatch関数を渡すことです。

// Naive implementation
import { createContext, useReducer } from 'react';

const AppContext = createContext();

const AppProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  );
};

これは小規模なアプリケーションでは完璧に機能しますが、スケールすると大規模なパフォーマンス上の欠陥、すなわち**コンテキスト伝播(Context Propagation)**を引き起こします。

stateが変更されるたびに、useContext(AppContext)を介してAppContextを消費するすべてのコンポーネントが再レンダリングされます。これは、コンポーネントが実際に更新されたステートの特定のスライスを使用しているかどうかに関係なく発生します。Reduxは、ストアの特定の部分のみを購読し、選択されたスライスが変更されていない場合は積極的にレンダリングをスキップするuseSelectorフックを介して、この落とし穴をネイティブに回避します。

最新の高性能アプリケーションでReduxを真に置き換えるには、この再レンダリングの問題を解決する必要があります。

Advertisement

アーキテクチャ最適化 1: ステートとディスパッチコンテキストの分割

最適化の旅の最初のステップは、ステートとディスパッチ関数を完全に別々のコンテキストに分割することです。useReducerによって返されるdispatch関数は安定しており、そのメモリ参照は再レンダリング間で決して変更されません。これを独自のプロバイダーに分離することで、アクション(「送信」や「切り替え」ボタンなど)をトリガーするだけでよいコンポーネントは、ステートが変更されても再レンダリングされることなくディスパッチコンテキストを消費できます。

import React, { createContext, useReducer } from 'react';

const StateContext = createContext(null);
const DispatchContext = createContext(null);

export const AppStateProvider = ({ children }) => {
  const [state, dispatch] = useReducer(rootReducer, initialState);

  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
};

これで、ThemeToggleButtonはDispatchContextを消費してTOGGLE_THEMEアクションを発火できます。dispatchへの参照は決して変更されないため、このコンポーネントはグローバルステートが更新されても無意味に再レンダリングされることはありません。

アーキテクチャ最適化 2: ドメイン駆動スライシング

ディスパッチが分離されていても、StateContextは依然としてグローバルステート全体を保持しています。ユーザーがプロフィール写真を更新すると、ユーザーのロールを読み取るためだけにStateContextを消費する深くネストされたSidebarMenuは、それでも再レンダリングされます。

モノリシックなストアの代わりに、ステートをドメインごとにスライスすべきです。Reactは、複数の独立したプロバイダーをエレガントに構成することを可能にします。

export const GlobalProvider = ({ children }) => (
  <AuthProvider>
    <ThemeProvider>
      <ShoppingCartProvider>
        {children}
      </ShoppingCartProvider>
    </ThemeProvider>
  </AuthProvider>
);

ステートをサイロ化することで、ショッピングカートの更新が、明示的にShoppingCartContextを消費するコンポーネントでのみ再レンダリングをトリガーするようにします。これはReduxのスライスに非常に似ていますが、Reactのネイティブなツリーアーキテクチャを活用しています。

アーキテクチャ最適化 3: メモ化によるコンテキストセレクター

ドメインスライシングを行っても、複雑なドメイン(例: UserContext)にはpermissions、preferences、activityLogのようなフィールドが含まれる場合があります。コンポーネントがpermissionsのみを必要とする場合、activityLogが変更されても再レンダリングされるべきではありません。

ReactのContext APIはセレクターをネイティブにサポートしていません(ただし、useContextSelectorの提案は進行中です)。しかし、Reduxの正確なレンダリング動作を模倣するために、高階コンポーネント(HOC)とReact.memoを使用することでこれを実現できます。

import React, { useContext, memo } from 'react';

// The presentation component is memoized
const UserProfile = memo(({ username, role }) => {
  console.log("UserProfile rendered!");
  return (
    <div className="profile-card">
      <h1>{username}</h1>
      <span>{role}</span>
    </div>
  );
});

// The wrapper component consumes context
const UserProfileContainer = () => {
  const { username, role, lastLogin } = useContext(UserContext);
  
  // lastLogin updates frequently, but UserProfile only receives username and role.
  // Because UserProfile is wrapped in memo, it will bail out of the reconciliation phase!
  return <UserProfile username={username} role={role} />;
};

このパターンは擬似セレクターとして機能します。UserProfileContainerはUserContextが変更されるたびに再レンダリングされますが、これは非常に高速です。しかし、メモ化された<UserProfile />を返すため、Reactは高価な仮想DOMの差分比較操作に到達する前にレンダリングフェーズを停止します。

Advertisement

ミドルウェアの再現: 強化されたuseReducer

Reduxの最大の強みの一つは、そのミドルウェアエコシステムです。これにより、ロギング、分析、非同期操作のためにアクションをインターセプトできます。ReactのネイティブなuseReducerをラップすることで、これを再現できます。

ロギングとサンク機能をuseReducerに追加するカスタムフックを構築しましょう。

import { useReducer, useCallback, useRef } from 'react';

const useEnhancedReducer = (reducer, initialState, middlewares = []) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const stateRef = useRef(state);
  
  // Keep state ref updated for middleware access
  stateRef.current = state;

  const enhancedDispatch = useCallback(
    (action) => {
      // Allow thunks (functions as actions)
      if (typeof action === 'function') {
        return action(enhancedDispatch, () => stateRef.current);
      }

      // Run pre-dispatch middlewares
      middlewares.forEach((mw) => mw(action, stateRef.current));

      // Native dispatch
      dispatch(action);
    },
    [middlewares]
  );

  return [state, enhancedDispatch];
};

この強化されたフックを使用すると、Reduxとまったく同じようにカスタムミドルウェアを渡すことができます。

const loggerMiddleware = (action, state) => {
  console.group(`Action: ${action.type}`);
  console.log('Previous State:', state);
  console.log('Payload:', action.payload);
  console.groupEnd();
};

const [state, dispatch] = useEnhancedReducer(
  rootReducer, 
  initialState, 
  [loggerMiddleware]
);

非同期ロジックをエレガントに管理する

Redux ThunkとRedux Sagaは、副作用を処理するためによく使用されます。カスタムミドルウェアランタイムを構築したくない場合、Contextでこれをエレガントに再現するにはどうすればよいでしょうか?

その答えは、非同期操作をdispatch関数をラップするカスタムフックに抽象化することにあります。

export const useAuthActions = () => {
  const dispatch = useContext(DispatchContext);

  const loginUser = async (credentials) => {
    dispatch({ type: 'LOGIN_REQUEST' });
    try {
      const response = await api.login(credentials);
      dispatch({ type: 'LOGIN_SUCCESS', payload: response.user });
    } catch (error) {
      dispatch({ type: 'LOGIN_FAILURE', payload: error.message });
    }
  };

  return { loginUser };
};

このパターンは、ビジネスロジックをUIコンポーネントから切り離します。プレゼンテーション層は単にloginUser(credentials)を呼び出すだけで、コンポーネントはAPIの実装に完全に依存しません。これはRedux Thunksとまったく同じ疎結合の利点を提供しますが、TypeScriptの推論が優れており、ミドルウェアのセットアップは不要です。

不変性とImmer

useReducerの一般的な問題点は、深くネストされたオブジェクトの更新です。Redux Toolkitは、Immerを最初から統合することでこれを解決します。Immerのproduceでリデューサーをラップすることで、同じ開発者体験を簡単に実現できます。

import { produce } from 'immer';

const userReducer = produce((draft, action) => {
  switch (action.type) {
    case 'UPDATE_PROFILE':
      // Direct mutation! Immer handles the immutability under the hood.
      draft.profile.address.city = action.payload;
      break;
  }
});

この単一の追加により、ネイティブなReactステートとRedux Toolkitの間の最大の人間工学的なギャップの1つが埋められます。

結論

React ContextとuseReducerでReduxを置き換えることは強力なアーキテクチャ上の選択ですが、それには敬意が必要です。素朴な実装は、アプリケーションが成長するにつれて、必然的に動作の遅いインターフェースとレンダリングストームを引き起こします。

ステートとディスパッチコンテキストを分割し、ステートをドメイン固有のプロバイダーにサイロ化し、セレクターのような動作のために高度なメモ化技術を利用し、ミドルウェアと非同期ロジックのためにカスタムフックを採用することで、Reduxと同等のパフォーマンスを持つステート管理アーキテクチャを構築できます。

さらに重要なのは、イディオマティックなReactパターンを採用し、依存関係ツリーを軽量に保ち、現代のフロントエンド開発を深く楽しいものにすることです。Reactはプリミティブを提供してくれました。それらをエレガントに設計するのは私たち次第です。

こちらもおすすめ

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement