🥐

React Context?Redux?離れた箇所のstateを更新する場合の再レンダリング最適化

2022/11/01に公開

概要

目的

下記のようなコンポーネント構成で、Activatorでstateを更新し、Indicatorだけが再レンダリングされるようにしたい。

<Parent>
    <Indicator /> ・・・stateの表示
    <Activator /> ・・・stateの更新
</Parent>

結論

Contextだけで実現するのは厳しい。Reduxを使うとスッキリする。Reduxのハードルは低い。

解説

問題のコード

下記の例では、「Activate」ボタンを押した時に再レンダリングが必要なのはIndicatorだけ。しかし実際はParentActivatorも再レンダリングされてしまいます。

import { useState } from "react";

const Parent = () => {
  console.log("render Parent");
  const [active, setActive] = useState(false);
  return (
    <>
      <Indicator value={active} />
      <Activator activator={setActive} />
    </>
  );
};
const Indicator = (props) => {
  console.log("render Indicator");
  return <div>{props.value ? "Active" : ""}</div>;
};
const Activator = (props) => {
  console.log("render Activator");
  return <button onClick={() => props.activator(true)}>Activate</button>;
};
export default Parent;

https://codesandbox.io/s/unnecessary-render-parent-and-childs-y5cpeh

親がstateを持ち、それを子に渡している限りはこの状況は解消できません。ということは、子の間だけでstateをやりとりする方法があれば良いはずです。

子にstateを持たせ、Context経由でstateを更新する

stateを使うところはIndicatorなので、そこにuseStateを置いてやります。しかしそれだとActivatorからstateを見られないので、Context経由でstate更新する関数を受け渡してあげます。

import { useState, useContext, createContext } from "react";

const MyContext = createContext();

const Parent = () => {
  console.log("render Parent");
  return (
    <MyContext.Provider value={{ active: false, activate: () => {} }}>
      <Indicator />
      <Activator />
    </MyContext.Provider>
  );
};
const Indicator = () => {
  console.log("render Indicator");
  const [active, setActivate] = useState(false);
  const context = useContext(MyContext);
  // activate関数を動的に定義する。
  context.activate = () => {
    setActivate(true);
  };

  return <div>{active ? "Active" : ""}</div>;
};
const Activator = () => {
  console.log("render Activator");
  const context = useContext(MyContext);
  // Indicatorで定義した関数をcontext経由で呼び出す。
  return <button onClick={() => context.activate(true)}>Activate</button>;
};
export default Parent;

https://codesandbox.io/s/unnecessary-render-parent-and-childs-solution-with-context-6wqyh5

一応当初目的は一部達成できますが、子コンポーネントで関数を上書きするのがバッドノウハウな感じがしますね。また、「Activate」ボタンを何度も押すとstateが変わらないのにIndicatorに再レンダリングがかかってしまうので、useMemoやuseCallbackを使うなど更なる改善の余地があります。

この方向性はよくないですね。

stateは外に持たせ、Reduxを使ってreducerでstateを更新する。

餅は餅屋。素直にReduxを使いましょう。最近はhooks APIが使えるのでスリムに書けるようになりました。

import { Provider, useSelector, useDispatch } from "react-redux";
import { configureStore, createSlice } from "@reduxjs/toolkit";

// -- ここから -- stateとreducerの定義。通常は別ファイルに書く。
const activeSlice = createSlice({
  name: "active",
  initialState: { value: false },
  reducers: {
    activate: (state) => {
      state.value = true;
    }
  }
});
const { activate } = activeSlice.actions;
// -- ここまで --

const myStore = configureStore({
  reducer: {
    active: activeSlice.reducer
  }
});

const Parent = () => {
  console.log("render Parent");
  return (
    <Provider store={myStore}>
      <Indicator />
      <Activator />
    </Provider>
  );
};
const Indicator = () => {
  console.log("render Indicator");
  const active = useSelector((state) => state.active.value);
  return <div>{active ? "Active" : ""}</div>;
};
const Activator = () => {
  console.log("render Activator");
  const dispatch = useDispatch();
  return <button onClick={() => dispatch(activate())}>Activate</button>;
};
export default Parent;

https://codesandbox.io/s/unnecessary-render-parent-and-childs-solution-with-redux-th10dq

stateとそれを更新する関数を一箇所で定義できるので見通しが良いですね。パフォーマンス改善の小手先の工夫などは不要で、必要な時だけIndicatorだけが更新される理想的な動きをしてくれます。

しかしContextと違ってグローバルなstate管理になってしまうので、モジュール性に欠けます。

Redux+Context

react-reduxではContextを渡すAPIが用意されています。これによりReduxを使いつつローカルなstate管理ができるようになります。

import { createContext } from "react";
import { Provider, createDispatchHook, createSelectorHook } from "react-redux";
import { configureStore, createSlice } from "@reduxjs/toolkit";

// -- ここから -- stateとreducerの定義。通常は別ファイルに書く。
const activeSlice = createSlice({
  name: "active",
  initialState: { value: false },
  reducers: {
    activate: (state) => {
      state.value = true;
    }
  }
});
const { activate } = activeSlice.actions;
// -- ここまで --

// Context、および専用のhook関数を生成。
const MyContext = createContext();
const useActiveDispatch = createDispatchHook(MyContext);
const useActiveSelector = createSelectorHook(MyContext);

const myStore = configureStore({
  reducer: {
    active: activeSlice.reducer
  }
});

const Parent = () => {
  console.log("render Parent");
  return (
    // ここでContextを指定する。
    <Provider context={MyContext} store={myStore}>
      <Indicator />
      <Activator />
    </Provider>
  );
};
const Indicator = () => {
  console.log("render Indicator");
  const active = useActiveSelector((state) => state.active.value);
  return <div>{active ? "Active" : ""}</div>;
};
const Activator = () => {
  console.log("render Activator");
  const dispatch = useActiveDispatch();
  return <button onClick={() => dispatch(activate())}>Activate</button>;
};
export default Parent;

https://codesandbox.io/s/unnecessary-render-parent-and-childs-solution-with-redux-and-context-x9eqie

Contextはいらない子?

得意不得意あるので使い分けが大事。こんな感じになるのかなと思います。

  • Contextが有用な場面

    • 親から子に値をstateをブロードキャストしたい場合
    • stateの変更がコンポーネント全体に影響する場合
  • Reduxが有用な場面

    • 別階層のコンポーネントのstateを変更する必要がある場合

まとめ

stateを親コンポーネントに持たせようと考えはじめてしまったら、こわがらずにReduxを検討しましょう。

Discussion