絵文字アイコン

React Contextのアンチパターンは本当にアンチパターンなのかを考える

ReactのContextに関する内容で、stateとsetStateを1つのvalueにまとめるとパフォーマンスが悪くなるので分けたほうがいいというものがあるが、果たしてそれは本当に最善なのだろうか?

2024/09/23: 「状態管理をする上でまず考えたいこと」のセクションを追加、表現や注釈の順番などを修正

はじめに

なんとなく使っていた React の Context について、ちゃんと理解できていなかったので深掘りしてみる。

検索してみるといくつかの記事で、コンテキストを使用するときのアンチパターンとして「state,setState を同じオブジェクトに入れるとパフォーマンスが悪くなる(再レンダリングが多く発生する)」というものがあった。
確かに、パフォーマンスが悪い場合にそれがボトルネックになっている可能性はあるので分ける方が良い場合もあると思うが、分けることによってコードを書く量が増えるなどデメリットもあるので、普段Contextを扱う際には何を意識するのが良いかを考えてみる。

ちなみに、JotaiやRecoilなどの状態管理ライブラリを使えばコード量が増える懸念をせずにパフォーマンス良く書けるが、これから話すことの前提として React の Context を使う場合を考える。

問題となっている、1つのオブジェクトのまとめる場合

以下のようなコンポーネントを作成して、どこで再レンダリングが発生するかを確認してみる。 今回は簡易的に Vite を使用する。

App.tsx
import { SetUserComponent1 } from './SetUserComponent1';
import { SetUserComponent2 } from './SetUserComponent2';
import { UserComponent } from './UserComponent';
import { UserProvider } from './UserProvider';

function App() {
  return (
    <UserProvider>
      <UserComponent />
      <SetUserComponent1 />
      <SetUserComponent2 />
    </UserProvider>
  );
}

export default App;
UserProvider.tsx
import { createContext, useContext, useState } from 'react';

interface User {
  id: number;
  name: string;
}

interface UserContextValue {
  user: User | null;
  setUser: (user: User) => void;
}

const UserContext = createContext<UserContextValue | null>(null);

export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

export const useUserContext = () => {
  const ctx = useContext(UserContext);
  if (!ctx) throw new Error('useUser must be used within a UserProvider');

  return { user: ctx.user, setUser: ctx.setUser };
};
UserComponent.tsx
import { useUserContext } from './UserProvider';

export function UserComponent() {
  const { user } = useUserContext();
  console.log('user', user);

  return (
    <section>
      <h2>User Component</h2>

      <p>user: {user?.name ?? 'null'}</p>
    </section>
  );
}
SetUserComponent1.tsx
import { useUserContext } from './UserProvider';

export function SetUserComponent1() {
  const { setUser } = useUserContext();
  console.log('setUser 1');

  const handleClick = () => {
    setUser({ id: 1, name: 'John Doe' });
  };

  return (
    <section>
      <h2>SetUser Component 1</h2>

      <button onClick={handleClick}>ユーザーを追加</button>
    </section>
  );
}
SetUserComponent2.tsx
import { useUserContext } from './UserProvider';

export function SetUserComponent2() {
  const { setUser } = useUserContext();
  console.log('setUser 2');

  const handleClick = () => {
    setUser({ id: 2, name: 'Jane Smith' });
  };

  return (
    <section>
      <h2>SetUser Component 2</h2
      <button onClick={handleClick}>ユーザーを追加</button>
    </section>
  );
}

value={{ user, setUser }}が肝で、usersetUser を 1 つのオブジェクトにまとめて value を入れている。

この状態でSetUserComponent1にあるボタンをクリックすると、

SetUserComponent1をクリックした後のログ

UserComponentSetUserComponent1SetUserComponent2 全てのコンポーネントで再レンダリングが発生していることがわかる。
userが変更されたのでUserComponentは再レンダリングされるのはわかるが、SetUserComponent1SetUserComponent2setUserだけを Context から受け取っているので再レンダリングされる必要がない。

この無駄な再レンダリングが発生する理由は以下である。1

  • React では、異なる value を受け取ると、Provider 配下の全てのコンポーネントが再レンダリングされる
  • 異なる value かどうかを判別するロジックとしてObject.isが使用されている
  • userの中身がsetUserにより変わったため、{ user, setUser } は異なるオブジェクトとなり、再レンダリングが発生する

無駄な再レンダリングの解決策

1つの解決策として、usersetUser を分ける方法がある。

UserProvider.tsx
// <User | null>の型だとcontextが取得できなかった
const UserContext = createContext<{ user: User | null } | null>(null);
const SetUserContext = createContext<((user: User) => void) | null>(null);

export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  // Memo化することでuserが同じである場合に新しいオブジェクトが生成されるのを防ぐ
  const userValue = useMemo(() => ({ user }), [user]);

  return (
    <UserContext.Provider value={{ user }}>
      <SetUserContext.Provider value={setUser}>
        {children}
      </SetUserContext.Provider>
    </UserContext.Provider>
  );
}

export const useUserContext = () => {
  const ctx = useContext(UserContext);
  if (!ctx) throw new Error('useUser must be used within a UserProvider');

  return ctx;
};

export const useSetUserContext = () => {
  const ctx = useContext(SetUserContext);
  if (!ctx) throw new Error('useSetUser must be used within a UserProvider');

  return { setUser: ctx };
};

この方法で再度試してみる。

SetUserComponent1をクリックした後のログ

SetUserComponent1 をクリックすると、UserComponent だけが再レンダリングされていることがわかる。 userが変更されてもSetUserContextの value、つまりsetUserは変化しないので再レンダリングが発生しない。

{ user, setUser }をuseMemoで囲う場合

{ user, setUser }useMemoで囲う方法もあるが、これだとuserが更新された場合に結局valueは新しいオブジェクトになるので、setUserだけを呼び出しているコンポーネントも再レンダリングされてしまう。 なので、先ほどのようにstateとsetStateを分ける方法の方が再レンダリングは抑えられる。

この場合、useMemoで囲うことで何が良いかというとUserProvider自体が何らかの理由で再レンダリングされた場合にuser,setUserが何も変わっていなければvalueに渡しているオブジェクトは再生成されなくなる。

1つのオブジェクトにまとめるのは本当にアンチパターンか?

本題に戻って、先ほど紹介した 1 つのオブジェクトにまとめる場合のメリット・デメリットを考えてみる。

  1. 1 つのオブジェクトにまとめる場合
    • メリット
      • Context や Provider の数自体や context の値を取得する関数が増えず、保守性・可読性が上がる
      • state,setState の関係がわかりやすい
    • デメリット
      • 無駄な再レンダリングが発生する場合がある

分ける場合のメリット・デメリットはこの逆である。 分ける場合はパフォーマンスと引き換えに、保守性・可読性を下げる可能性があることは忘れないでおきたい。

他の根拠となる情報

React の useContext の説明で挙げられている例

Context の使用例として、以下でvalue={{ currentUser, setCurrentUser }}が使われている。

もし、1 つにまとめるのが本当に問題であればこの方法を載せないか、注意書きや非推奨などのコメントがあるのではないかと思う。

Kent C. Dodds さんの記事

そもそもなぜ不必要な再レンダリングが良くないと思われているだろうか? Kent さんは以下のように説明している。2

「DOM は遅い」という話を聞いたことがある人は多いですが、コンポーネントが再レンダリングされても必ずしも DOM が更新されるわけではないことを理解していない人も多いです。 この誤解のために、コンポーネントが実際には DOM を更新する必要がないときでも、レンダリングがパフォーマンスのボトルネックになると信じてしまうのです。

確かに、これは場合によっては問題になることがありますが、一般的には低スペックのモバイルデバイスでもオブジェクトの作成(レンダーフェーズ)や比較(リコンサイルフェーズ)は非常に高速です。

また、Context を最適化する方法を紹介している記事3では、

もしコードが遅くなるかもしれないと思ってこういったことをするつもりなら、やめておいた方がいいです。冗談ではありません。 React は本当に高速で、パフォーマンスが十分に良いときにパフォーマンスの名の下に複雑さを追加するのは、「複雑さの予算」を無駄にするだけです。

なので、特にパフォーマンスがボトルネックになっていないなら、無駄な再レンダリングをそこまで気にする必要はないし、他の原因(例えば API のレスポンスが遅いなど)を見直すべきだと思う。

状態管理をする上でまず考えたいこと

はじめにで少し述べたが、状態管理をする際にReact Contextを直接使わず、JotaiやRecoilなどの状態管理ライブラリを使うことも考えられる。しかし、これらのライブラリを導入することを最初に試すのではなく、まずは以下の順に考えてみると良いかもしれない。

  1. prop drilling(またはthreadingprop downバケツリレー)で解決可能か?

    親から子コンポーネントに直接、明示的に props を渡すことで状態を管理する方法。明示的であるとどこでどんな値が入っているのかを追いやすく、保守がしやすい。

    コンポーネントの階層が深くなるとバケツリレーが大変なので、ルートでデータを共有できる方法に変更しがちだが、まずは適切なコンポーネント分割や設計を行うことでprop drillingでも可読性を下げずに状態管理ができないかを考えたい。
    適切なコンポーネント分割や設計とは、不必要に複数のコンポーネントに分けて抽象化しすぎないことや、実装したい機能に対して可読性・保守性を下げるような複雑なコンポーネントの作り方をしないことを指す。
    分割のタイミングについてはKentさんのWhen to break up a component into multiple componentsが参考になりそう。

  2. React Context APIを使う

    1で試したけどどうしても複雑になってしまう場合は次にReact Context APIを試してみる。
    使う際は、Providerをどこに置くか(コロケーションを意識する)や、どうContextを分けるか、場合によってはこの記事で述べたようなパフォーマンスの問題が発生することは念頭においておきたい。(ボトルネックになっていな限り気にしなくては良いが) また、1と比較して明示的なデータ共有ではなくなるので、どこで・どんな順序で・どんな値が入っているのかを追いにくくなることも理解しておきたい。

  3. 状態管理ライブラリを使う

    1,2で解決できない場合の最終手段として状態管理ライブラリを使う。
    Contextを多くの場所で使っており書くコードの量を節約したい場合や、再レンダリングによるパフォーマンスがボトルネックになっている場合などで使用するか考える。
    ただ、依存関係が一つ増えることや、こういう状態管理ライブラリがあることであまり何も考えずに軽く使ってしまうこともあるので、まずは1,2で解決できないかを優先的に考えたい。

    個人的な経験談としてJotaiを使用していたが、現状のコンポーネントが複雑でルートでデータ共有しないと大変だったり、コロケーションを意識したストアの作り方が適切にできていなかったことで、どこでどんな値が入ってくるかを追うのが本当に大変だったので、あまり使いたくないという気持ちがある。気軽に使うものではない。

まとめ

状態管理は難しい!

(おまけ)useState の仕組み

そもそも、なぜ set 関数側は値が変わっていないと判断されるのだろうか?(state 側が値が変わるのは自明だけども)

この理由についてわかっている人も多いと思うが、自分は恥ずかしながら set 関数は state が変わるたびに値が変化する(再生成される)のではないかと漠然と思っていた。 また、コンポーネントの中で作成した関数は何もしないと毎回新しく生成されるため、set 関数でも同じような挙動をするのではないかと思っていた。 (よく考えたら再生成されるのは、UserProvider 自体が再レンダリングしないと発生しないので 2 つ目の理由は見当違い)

ふにゃふにゃ理解すぎるので、useState についてちゃんと調べてみよう!

useState の仕組みについては、以下のようになっている。

  • useState は React 側が用意した関数であり、グローバルに定義されているものである(コンポーネントの中で実行されているからといって再生成されるものではない)
  • 型はfunction useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]であり、[state, setState]のタプルを返す4
  • setStateは内部で定義されている純粋関数5であり、React 側で管理している state の値を更新するためだけの決まった関数である

🔍 内部のコードを見てみる

※興味ない人は飛ばしてもいい

React のソースコードを見てみる6

react/packages/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount: Dispatcher = {
  ~~~
  useState: mountState,
  ~~~
};

名前からわかるように useState というフックがマウントされる時にはmountStateという関数が呼ばれるようである。

このオブジェクトが使用される部分を見ると、

export function renderWithHooks<Props, SecondArg>という関数の中でHooksDispatcherOnMountが使用されている。

react/packages/react-reconciler/src/ReactFiberHooks.js
~~~
ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
~~~

ReactSharedInternals.HHooksDispatcherOnMountが設定されていることがわかる。

react/packages/react-reconciler/src/ReactFiberHooks.js
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;

  // これ
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);

  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
    initialState = initialStateInitializer();
    if (shouldDoubleInvokeUserFnsInHooksDEV) {
      setIsStrictModeForDevtools(true);
      // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
      initialStateInitializer();
      setIsStrictModeForDevtools(false);
    }
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

mountState関数ではdispatch関数を生成して、[state, setState]のタプルを返していそう。 また、mountStateImplqueueというオブジェクトを作成しており、dispatchというプロパティがあることがわかる。

react/packages/react-reconciler/src/ReactFiberHooks.js
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  if (__DEV__) {
    if (typeof arguments[3] === 'function') {
      console.error(
        "State updates from the useState() and useReducer() Hooks don't support the " +
          'second callback argument. To execute a side effect after ' +
          'rendering, declare it in the component body with useEffect().',
      );
    }
  }

  const lane = requestUpdateLane(fiber);
  const didScheduleUpdate = dispatchSetStateInternal(
    fiber,
    queue,
    action,
    lane,
  );
  if (didScheduleUpdate) {
    startUpdateTimerByLane(lane);
  }
  markUpdateInDevTools(fiber, lane, action);
}

function dispatchSetStateInternal<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
  lane: Lane,
): boolean {
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher = null;
        if (__DEV__) {
          prevDispatcher = ReactSharedInternals.H;
          ReactSharedInternals.H = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            // TODO: Do we still need to entangle transitions in this case?
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return false;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactSharedInternals.H = prevDispatcher;
          }
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }
  }
  return false;
}

dispatchSetStateInternalが set 関数の本体のようである。 ここではconst eagerState = lastRenderedReducer(currentState, action);の部分でeager(先行、即座などの意)な state を計算している。

const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };

lastRenderedReducerを探すと、queueの初期化時にbasicStateReducerが設定されている。

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
  return typeof action === 'function' ? action(state) : action;
}

action に関数が渡された場合はその関数を実行し、それ以外の場合はそのまま返すようになっている。 ここで受け取っている action は例えばsetUser({ id: 1, name: 'John Doe' });{ id: 1, name: 'John Doe' }のことを指している。

流れとしては、set 関数を実行したときに渡す値がeagerStateとなり、is(eagerState, currentState)の部分でcurrentStateと違う値かどうかを判別し、違うと判断された場合にキューとして管理されている state を更新し、再レンダリングを発生させる。

const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);

このタプルとしてセットされている dispatch に戻るとdispatchSetState.bindが使用されており、dispatchSetStateでは第 3 引数にactionが渡されている。 この action は set 関数を呼び出す時に渡される引数のことであり、先ほどのbasicStateReducerに渡されている。

set 関数の再生成について結論

以上から、最初の問いに対する答えとしては、set 関数はdispatchとして扱われている、コンポーネントの外にグローバルで定義されている決まった関数であるため、何らかの影響により再生成されるものではないということがわかった。 確かに、よく考えれば値を更新するだけの固定の関数になるはずである。

参考情報

https://kentcdodds.com/blog/application-state-management-with-react

Footnotes

  1. useContextの注意点

  2. Unnecessary re-renders

  3. How to optimize your context value

  4. 厳密にはオーバーロードのfunction useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>]もある

  5. 純粋性:コンポーネントとは数式のようなもの

  6. https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js#L1920-L1932

GitHubで編集を提案