メモ化とは同じ入力が再度発生した時に、キャッシュした結果を返すことです。
この記事ではReact及びreact-reduxにおけるレンダリングのタイミングを振り返りながら、メモ化を使用するタイミングについて整理していこうと思います。
Reactのレンダリングタイミング
そもそもReactデフォルトの機能でコンポーネントの再レンダリングが走るタイミングは以下のパターンになります。
- 親コンポーネントがレンダリングされた場合(子コンポーネントのpropsに変更があったかは関与しない)
- stateが変更された場合
- クラスコンポーネントの
this.setState()
- 関数コンポーネントの
useState
のセッター - 関数コンポーネントの
useReducer
のdispatch()
- クラスコンポーネントの
- ContextのValueが変更された場合(ConsumerComponent限定)
※stateが変更された場合は、変更前と変更後でstateをshallow比較して差分があった時のみ再レンダリングされる
※強制レンダリングであるforceUpdate
の実行は除く
上記を踏まえて、React及びReduxを使用する場合にどのタイミングでレンダリングが実行されるか見てみましょう。
通常のReact
親→子にpropsを渡さない場合
export const ParentComponent = () => {
const [a, setA] = useState(0);
const onClick = () => setA(a + 1);
return (
<>
<button onClick={onClick}>CountAボタン</button>
<p>CountA: {a}</p>
<ChildComponent/>
</>
);
};
Reactは親コンポーネントがレンダリングされるとその子コンポーネントをレンダリング、そのまた孫コンポーネントをレンダリング...というようにネストしているコンポーネントを再帰的にレンダリングします。
すなわち、上記例でCountAボタンをクリックすると以下のフローでレンダリングが実行されます。
①useStateのセッター(setA)が実行される
②実行されたセッターを持つコンポーネントであるParentComponentがレンダリングされる
③ParentComponenがレンダリングされたので、その子コンポーネントであるChildComponentがレンダリングされる
これはReactのデフォルトの機能であり、子コンポーネントにpropsを渡しているかどうかは関係ありません。
親→子にpropsを渡す場合
export const ParentComponent = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClickA = () => setA(a + 1);
const onClickB = () => setB(b + 1);
return (
<>
<button onClick={onClickA}>CountAボタン</button>
<button onClick={onClickB}>CountBボタン</button>
<ChildComponent a={a} />
<p>CountB: {b}</p>
</>
);
};
export const ChildComponent = (props) => {
return <p>CountA: {props.a}</p>;
};
ここで重要なのは親から子への再帰的レンダリングにおいて、Reactは子にpropsが渡されたかどうかだけではなく、propsが変更されたかどうかさえも気にしないということです。
つまりここでCountBボタンをクリックすると、
①useStateのセッター(setB)が実行される
②実行されたセッターを持つコンポーネントであるParentComponentがレンダリングされる
③ParentComponenがレンダリングされたので、その子コンポーネントであるChildComponentがレンダリングされる
と、上記のように1つ目の例と全く同じレンダリングプロセスを辿ります。
memo
propsに差分がないにも関わらずレンダリングを行うのは、パフォーマンス観点では不要な処理になります。
これを防ぐために、ReactにはmemoというHigher Order Component(コンポーネントを受け取って新規のコンポーネントを返す関数)が用意されています。
export const ChildComponent = React.memo((props) => {
return <p>CountA: {props.a}</p>;
});
子コンポーネントをReact.memoを使ってメモ化すれば、受け取ったpropsが変更されていない場合にそのコンポーネントの再レンダリングを防ぐことができます。
よってChildComponentをメモ化してParentComponentのCountBボタンを押下した場合のレンダリングフローは以下のようになり、不要な子コンポーネントのレンダリングをスキップすることができます。
①useStateのセッター(setB)が実行される
②実行されたセッターを持つコンポーネントであるParentComponentがレンダリングされる
③ParentComponenがレンダリングされたが、その子コンポーネントであるChildComponentのpropsには差分がないので、メモ化の仕組みによりChildComponentはレンダリングされない
useCallback
またpropsとして関数を渡す場合、親コンポーネントのレンダリングのたびに新しい関数インスタンスが生成されてしまうため、propsに差分があるとみなされReact.memoが機能しません。
その場合、あらかじめその関数をuseCallbackで囲む必要があります。
export const ParentComponent = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
// propsとして渡す関数をuseCallbackで囲む
const onClickA = useCallback(() => setA(a + 1), [a]);
const onClickB = () => setB(b + 1);
return (
<div>
<ChildComponent a={a} onClickA={onClickA} />
<button onClick={onClickB}>CountBボタン</button>
<p>CountB: {b}</p>
</div>
);
};
export const ChildComponent = React.memo((props) => {
return (
<>
<button onClick={props.onClickA}>CountAボタン</button>
<p>CountA: {props.a}</p>
</>
);
});
useCallbackを使うことで、レンダリング時にuseCallbackの依存配列の値が変化しない場合は、新しい関数インスタンスの生成を防ぐことができます(キャッシュされた関数インスタンスを使用するため)。
よってCountBボタンを押下した場合にReact.memoの機能によってChildComponentの再レンダリングを防ぐことができます。
useCallbackの使用基準についてはこちらの記事が参考になります。
useCallbackはとにかく使え! 特にカスタムフックでは - uhyo/blog
useMemo
またpropsとしてオブジェクトを渡す場合も、親コンポーネントのレンダリングのたびに新しいオブジェクトが生成されてしまうため、propsに差分があるとみなされReact.memoが機能しません。
この場合はuseMemoを使用します。
export const ParentComponent = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClickA = () => setA(a + 1);
const onClickB = () => setB(b + 1);
// propsとして渡すオブジェクトをuseMemoで囲む
const obj = useMemo(() => ({countA: a}), [a])
return (
<>
<button onClick={onClickA}>CountAボタン</button>
<button onClick={onClickB}>CountBボタン</button>
<ChildComponent obj={obj} />
<p>CountB: {b}</p>
</>
);
};
export const ChildComponent = React.memo((props) => {
return <p>CountA: {props.obj.countA}</p>;
});
useMemoを使うことで、レンダリング時にuseMemoの依存配列の値が変化しない場合は、新しいオブジェクトの生成を防ぐことができます(キャッシュされたオブジェクトを使用するため)。
よってCountBボタンを押下した場合にReact.memoの機能によってChildComponentの再レンダリングを防ぐことができます。
useMemoの使用基準についてはこちらの記事が参考になります。
結局useMemoはいつ使えばいいの? 僕の決定版 - Qiita
Context
次にContextを使用するパターンを見ていきます。
export const ParentComponent = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClickA = () => setA(a + 1);
const onClickB = () => setB(b + 1);
const contextValue = {a, b};
return (
<>
<MyContext.Provider value={contextValue}>
<button onClick={onClickA}>CountAボタン</button>
<button onClick={onClickB}>CountBボタン</button>
<p>CountA: {a}</p>
<ChildComponent/>
</MyContext.Provider>
</>
);
};
export const ChildComponent = () => {
const count = useContext(MyContext)
return <p>CountB: {count.b}</p>;
};
上記例ではCountAボタン、CountBボタンのどちらを押下した場合でも以下のレンダリングフローが実行されます。
①useStateのセッターが実行される
②実行されたセッターを持つコンポーネントであるParentComponentがレンダリングされる
③ParentComponenがレンダリングされたので、その子コンポーネントであるChildComponentがレンダリングされる
一見React.memoを使用すればChildComponentのレンダリングは防げそうですが、そう簡単にはいきません。
なぜならContextを使用した場合、そのProvider内のすべてのConsumerComponentは、Providerのvalueが更新される度に再レンダリングされるからです。
この場合の子コンポーネントの不要レンダリングを防ぐ方法としては、Contextを分割して複数のProviderを使うなどがあります。
export const ParentComponent = () => {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClickA = () => setA(a + 1);
const onClickB = () => setB(b + 1);
// Providerに渡すvalueを分割する
const contextValueA = a;
const contextValueB = b;
// 複数のProviderを使用する
return (
<>
<MyContextA.Provider value={contextValueA}>
<MyContextB.Provider value={contextValueB}>
<button onClick={onClickA}>CountAボタン</button>
<button onClick={onClickB}>CountBボタン</button>
<p>CountA: {a}</p>
<ChildComponent/>
</MyContextB.Provider>
</MyContextA.Provider>
</>
);
};
export const ChildComponent = React.memo(() => {
const b = useContext(MyContextB)
return <p>CountB: {b}</p>;
});
これでMyContextAのvalueが更新されても、ChildComponentはMyContextAのConsumerではないので、不要なレンダリングは行われません。
ただ、このやり方だとProviderがいくつも作られてしまい現実的ではないので、別のやり方としてConsumerコンポーネントをできるだけ細かく分割する方法もあります。
export const ChildComponent = React.memo(() => {
return (
<>
{/* 省略 */}
<GrandChildComponent />
</>
);
});
export const GrandChildComponent = () => {
const count = useContext(MyContext)
return <p>CountB: {count.b}</p>;
};
この状態でParentComponentのCountボタンを押下した場合は以下のレンダリングフローとなり、ContextValue更新における再レンダリングComponentを最小限に収めることができます。
①useStateのセッターが実行される
②実行されたセッターを持つコンポーネントであるParentComponentがレンダリングされる
③ParentComponenがレンダリングされたが、その子コンポーネントであるChildComponentのpropsは変更が起きていないので、メモ化の仕組みによりChildComponentはレンダリングされない
④ContextValueが更新されたので、ConsumerコンポーネントであるGrandChildComponentがレンダリングされる
その他Contextに関するパフォーマンス改善のやり方に関しては、以下の記事が詳しいです。
React の Context の更新による不要な再レンダリングを防ぐ 〜useContext を利用した時に発生する不要な再レンダリングを防ぐ方法に関して〜 - Qiita
connect
ここからはReact Reduxについて見ていきます。
まずはHigher Order Componentであるconnectを使用する場合です。
const ParentComponent = (props) => {
const onClickA = () => props.setA();
const onClickB = () => props.setB();
return (
<>
<button onClick={onClickA}>CountAボタン</button>
<button onClick={onClickB}>CountBボタン</button>
<p>CountA: {props.a}</p>
<ChildComponent/>
</>
);
};
const mapStateToProps = (state) => ({ a: state.a });
const mapDispatchToProps = (dispatch) => ({
setA: () => dispatch({type: "COUNTA"}),
setB: () => dispatch({type: "COUNTB"}),
});
export default connect(mapStateToProps, mapDispatchToProps)(ParentComponent);
const ChildComponent = (props) => {
return <p>CountB: {props.b}</p>;
};
const mapStateToProps = (state) => { b: state.b };
export default connect(mapStateToProps)(ChildComponent);
connectはReact.memoと同様に動作し、受け取ったpropsに変更がある場合のみ、そのコンポーネントをレンダリングします。
つまり、親コンポーネントがレンダリングされても、子コンポーネントのpropsに差分がない場合は、子コンポーネントはレンダリングされません。
よってCountAボタンを押下した場合は以下のレンダリングフローとなります。
①親コンポーネントのsetAが実行され、reduxのstoreにアクションがdispatchされる
②storeのstateが更新される
③props.aに差分が出たため、親コンポーネントがレンダリングされる
④props.bには差分がないため、connectの機能により子コンポーネントはレンダリングされない
したがってconnectされているコンポーネントにはReact.memoを使う必要はありません。
// connectされたコンポーネントではReact.memoの意味がない
const ChildComponent = React.memo((props) => {
return <p>CountB: {props.b}</p>;
});
const mapStateToProps = (state) => { b: state.b };
export default connect(mapStateToProps)(ChildComponent);
useSelector
次にuseSelectorを使用する場合です。
export const ParentComponent = () => {
const a = useSelector(state => state.a);
const dispatch = useDispatch();
const onClickA = () => dispatch({type: "COUNTA"});
return (
<>
<button onClick={onClickA}>CountAボタン</button>
<p>CountA: {a}</p>
<ChildComponent/>
</>
);
};
export const ChildComponent = () => {
const b = useSelector(state => state.b);
const dispatch = useDispatch();
const onClickB = () => dispatch({type: "COUNTB"});
return (
<>
<button onClick={onClickB}>CountBボタン</button>
<p>CountB: {b}</p>
</>
);
};
CountAボタンを押下した場合は以下レンダリングフローとなります。
①useDispatchフックにより、reduxのstoreにアクションがdispatchされる
②storeのstateが更新される
③props.aに差分が出たため、親コンポーネントがレンダリングされる
④props.bには差分がないが、ParentComponenがレンダリングされたため、その子コンポーネントであるChildComponentがレンダリングされる
useSelectorはconnectと異なり、親コンポーネントがレンダリングされる際に子コンポーネントのレンダリングを止めることはできません。
そのためconnectと同様にpropsの差分がない子コンポーネントのレンダリングを防ぐためには、React.memoを使用する必要があります。
export const ChildComponent = React.memo(() => {
const b = useSelector(state => state.b);
return <p>CountB: {b}</p>;
});
また、CountBボタンを押下した場合はChildComponentのみがレンダリングされます。
これはuseSelectorは任意のアクションが発行(dispatch)されるたびに毎回実行されますが、
取得するstateの差分がない場合はそのコンポーネントのレンダリングをスキップするという特性があるためです。
shallowEqual
しかし、useSelectorで取得するstateがオブジェクトの場合、
actionがdispatchされるたびにstateも別のインスタンスとして生成されるため、
取得するpropsに差分があるとみなされ不要なレンダリングが起こってしまいます。
これを防ぐために、reduxにはshallowEqualという関数が用意されており、
useSelectorの第2引数にこれを指定することで、オブジェクトの浅い比較を実現してくれます。
const obj = useSelector(state => state.obj, shallowEqual);
このように書けば、actionがdispatchされても取得するstateオブジェクトの値が変更されてない場合は、
そのuseSelectorを実行しているコンポーネントは再レンダリングされません。
reselect
先程述べたように、useSelectorは任意のactionがdisptachされるたびに毎回実行されます。
そのため、selector関数内に重い処理がある場合、関係のないstateの更新でも毎回その処理が実行されてしまうため、パフォーマンスの劣化に繋がることがあります。
// 任意のactionがdisptachされるたびにheavyFunctionが実行されてしまう
const a = useSelector(state => heavyFunction(state.a));
これを防ぐやり方として、reselectというライブラリを使用する方法があります。(Redux Toolkitではデフォルトでインストールされています)
reselectを使用するとselector関数の結果をメモ化することができ、取得するstateに差分がない場合は、selector関数の実行を防ぐことができます。
// state.aの値に差分がない場合は、heavyFunctionを実行しない
const selectState = createSelector(
state => state.a,
a => heavyFunction(a)
);
export const ParentComponent = () => {
const a = useSelector(selectState);
// 以下省略
};
まとめ
- Reactデフォルトの機能として親コンポーネントがレンダリングされるとネストされている子コンポーネントも再帰的にレンダリングされる
- React.memoでpropsに差分がない場合の再レンダリングを防ぐことができる
- 関数をpropsとして渡す場合はuseCallbackを使う
- オブジェクトをpropsとして渡す場合はuseMemoを使う
- Contextを使用する場合はConsumerコンポーネントを細分化したほうが不要なレンダリングは防ぎやすい
- connectはReact.memoと同様に機能し、propsの差分がない場合はそのコンポーネントの再レンダリングをスキップする
- useSelectorはconnectとは異なり、React.memoと併用することで不要なレンダリングを防げる
- useSelectorでオブジェクトを取得する場合は第2引数にshallowEqualを設定する
- reselectを使用することでuseSelectorのselector関数もメモ化することができる