ReactのContextを分割してもレンダリングされてしまう問題
2025/7/28
Eyecatch

ABOUT

ReactのContextを分割することで再レンダリングを防ぐという手法があります。レンダリングの最適化の話です。

これに関する記事はいくつかあります。

今作っているWebアプリでこれを使いたい機会があったのですが、当初のコードでは再レンダリングが防ぐことができず数時間戦いました。

原因が判明したのでもし同じような症状の人がいたら参考にしてください。

原因

hookのdispatch(api)の部分がオブジェクトだった。

解決方法

オブジェクトとして使い続ける場合はオブジェクトをuseMemoでラップする。

単にdispatchが1つの関数しかなくオブジェクト化しなくても良いならばオブジェクト化しない状態で返す。

解説

不要な再レンダリングを防ぐためにContextをstate用とdispatch用に分割しますね。DispatchContextに格納するdispatch関数はコールバック化されています。(stateの更新による再レンダリング時にdispatch関数も一緒に更新されてしまうのを防ぐために、dispatch関数をuseCallbackでラップします。)

ソースコードは初めに紹介した記事の2つ目を参考にしてください。

以下はその記事にあるhookの正しい普通のコードです。(冒頭で書いたdispatch関数はinc関数のことになります。)

正しい普通のコード
function useCounter() {
    const [count, setCount] = useState(0);
    const inc = useCallback(() => setCount(prev => prev + 1), []);
    return [count, inc];
}

次に問題のあるコードです。

問題のあるコード
function useCounter() {
    const [count, setCount] = useState(0);
    const inc = useCallback(() => setCount(prev => prev + 1), []);
    return [count, { inc } ];
}

変わっている部分は4行目のreturnによる返り値です。配列の2つ目がinc から{ inc } になっています。これが再レンダリングの起きる原因です。

Reactの仕様として差分を検出する際にはObject.is() というのが使われているといいます。正しいコードの方ではinc関数がコールバック化されている(レンダリングの前後で値が変わらない)ので、このinc関数を保持するDispatchContextは変更を検出しません。

対して問題のあるコードは返り値を生成する際に、inc関数をオブジェクトとしてまとめたものがDispatchContextに格納されます。ReactのHookはstateの更新があると自分自身がもう一度呼び出されます(hook内にConsole.logを書いてみれば状態が更新されるたびにコンソール出力されることがわかります)。そのため、stateが更新されるたびに、コールバック化されたinc関数をオブジェクト化した新しいオブジェクトがDispatchContextに格納されることになります。もちろんinc関数自体はコールバック化されているのでstate更新前後の間で同じfunctionを参照していますが関係ありません。これは先程書いたObject.is() による仕様です。

ではどうすれば良いのか?hook内の複数の関数をオブジェクトにまとめてhookの機能としたい場合もあります。先程のソースコードにdec関数を加えた場合について書きます。

まずは問題のあるコードです。

問題のあるコード
function useCounter() {
    const [count, setCount] = useState(0);
    const inc = useCallback(() => setCount(prev => prev + 1), []);
    const dec = useCallback(() => setCount(prev => prev - 1), []);
    return [count, { inc, dec } ];
}

次にこれを解消したコードです。

正しいコード
function useCounter() {
    const [count, setCount] = useState(0);
    const inc = useCallback(() => setCount(prev => prev + 1), []);
    const dec = useCallback(() => setCount(prev => prev - 1), []);
    const dispatch = useMemo(() => ({ inc, dec }), [inc, dec]);
    return [count, dispatch];
}

このように、dispatchオブジェクトをuseMemo関数を用いてメモ化します。dispatchオブジェクトはinc関数もしくはdec関数の値が変更されたときに更新されますが、inc関数もdec関数もコールバック化されているので更新はされません。つまり、useCounter関数が何度再レンダリングによって呼び出されてもdispatchの部分は常に同じオブジェクトとなります。

こうすることで、元の記事と同じようにContextを分割することによって再レンダリングを防ぐことができます。

あとがき

ReactとJavaScriptのオブジェクトに関する仕様は非常にセンシティブです。常に意識していないと、なんで動かない?どうして更新されない?といった感じに躓きます。

1番良いのはオブジェクトを使用しないということですがそんなの無理ですよね。JavaScriptがベースにある以上この問題は避けられないと思います。

もし参考になったら幸いです。読んでいただきありがとうございます。