valtioで不要な再レンダリングを減らすためのtips
はじめに
最近、シンプルな状態管理ライブラリであるvaltioを使っています。
状態をmutableなproxyオブジェクトで管理し、そのproxyオブジェクトを直接更新することで、どこからでも状態の更新が可能です。
Reactのコンポーネント内でproxyオブジェクトを参照する場合、proxyオブジェクトを更新してもコンポーネントは自動的に再レンダリングされず、更新に伴い再レンダリングさせるためにはuseSnapshotというhookを使ってproxyオブジェクトのsnapshotを取る必要があります。
自分はこのsnapshotによるレンダリングの挙動への理解が浅く、無駄な再レンダリングを増やしてしまっていました。
そこで、無駄な再レンダリングを増やさないための方法を書き残します。
結論から言うと
snapshotを参照するコンポーネント内でuseSnapshotを使ってsnapshotを取得するのが最もレンダリングを削減できます。
snapshotを取得した後にバケツリレーして色々なコンポーネントで参照するとレンダリングが増えます。
どういうことかこの後説明します。
snapshotを取る位置を誤ると無駄な再レンダリングが生じる
例えば以下のような2つのプロパティを持つproxyオブジェクトがあったとします。
const state = proxy({ a: false, b: false });
このオブジェクトをそれぞれ表示するコンポーネントA, Bがあったとします。
proxyオブジェクトの更新に応じてコンポーネントを再レンダリングするためには当然useSnapshotを使ってsnapshotを取得します。
const snapshot = useSnapshot(state)
以前、自分はproxyオブジェクトのsnapshotを取ると、オブジェクトのプロパティの一部を更新した場合、そのオブジェクトのsnapshotを取得している箇所全てが再レンダリングされるのだと思っていました。
今回の例で言うと、state.a = true
のようにプロパティの一部を更新するとconst snapshot = useSnapshot(state)
を呼び出している全ての箇所で再レンダリングが走ると思っていました。
そのため、A. 親コンポーネントで先にsnapshotを取得して子コンポーネントにsnapshotを渡す
のとB. 子コンポーネントそれぞれでsnapshotを取得
するのでレンダリングコストはほとんど変わらないと思っていました。useSnapshotの呼び出し回数が減るのでAの書き方の方が良いとさえ思っていました。(コードの詳細はアコーディオン内に記載します)
A. 親コンポーネントで先にsnapshotを取得して子コンポーネントにsnapshotを渡す
const state = proxy({ a: false, b: false });
const ComponentA = () => {
const snapshot = useSnapshot(state);
console.log("a rerendered");
return (
<button onClick={() => (state.a = !state.a)}>{`A ${snapshot.a}`}</button>
);
};
const ComponentB = () => {
const snapshot = useSnapshot(state);
console.log("b rerendered");
return (
<button onClick={() => (state.b = !state.b)}>{`B ${snapshot.b}`}</button>
);
};
const App = () => {
return (
<>
<div>App Component</div>
<ComponentA />
<ComponentB />
</>
);
};
B. 子コンポーネントそれぞれでsnapshotを取得
const state = proxy({ a: false, b: false });
const ComponentA = () => {
const snapshot = useSnapshot(state);
console.log("a rerendered");
return (
<button onClick={() => (state.a = !state.a)}>{`A ${snapshot.a}`}</button>
);
};
const ComponentB = () => {
const snapshot = useSnapshot(state);
console.log("b rerendered");
return (
<button onClick={() => (state.b = !state.b)}>{`B ${snapshot.b}`}</button>
);
};
const App = () => {
return (
<>
<ComponentA />
<ComponentB />
</>
);
};
ところが、どうやらvaltioはsnapshotの中のアクセスされているプロパティまで見て、その要素が変更された時だけ再レンダリングが発生するように最適化しているようです。(あるオブジェクトのsnapshotを取得した場合、そのオブジェクトの一部のプロパティを更新した場合に問答無用で再レンダリングが発生するわけではなく、snapshot中のアクセスしたプロパティだけの更新をトリガーに再レンダリングが発生)
2023/10/9時点の公式ドキュメントにもしっかり書いてありました。
The component will only re-render when the parts of the state you access have changed, it is render-optimized.
なので、Aの方法だと片方のプロパティを更新すると両方のコンポーネントが再レンダリングされてしまいますが、Bの方法だと片方のコンポーネントだけを再レンダリングさせることが可能です。
そのため、たとえ取得するsnapshotが同じだったとしても、親コンポーネントでsnapshotを取得し、それを子コンポーネントに渡すのではなく、子コンポーネントごとにsnapshotを取った方が再レンダリングが少なくなります。
(ちなみにですが、snapshotだけ取って何のプロパティにもアクセスしない場合は、全てのプロパティの変更を監視して再レンダリングが発生するようです)
const ComponentA = () => {
useSnapshot(state); // プロパティa, bどちらの変更時も再renderされる。
console.log("a rerendered");
return (
<button onClick={() => (state.a = !state.a)}>{`A ${state.a}`}</button>
);
};
配列のsnapshotを取る際の注意
配列のsnapshotを取る場合は不要な再レンダリングが発生しやすいので注意が必要です。
ここで、配列のproxyオブジェクトがあったとします。
const arrayState: {a: boolean}[] = proxy([{a: true}, {a: false}]);
このオブジェクトに要素を追加したり、削除したりした時に再レンダリングを実行するためにはsnapshotに対してmapする必要があります。
const ParentComponent = () => {
const snapshot = useSnapshot(arrayState);
return snapshot.map((s, index) => (<ChildComponent snapshot={s} index={index} />));
}
const ChildComponent = (snapshot: {a: boolean}, index: number) => {
return <button onClick={() => (arrayState[index].a = !arrayState[index].a)}>{`${snapshot.a}`}</button>;
}
ここで、配列のsnapshotに対してmapをすると、配列の一部の要素を更新した際に、親コンポーネントと全ての子コンポーネントで再レンダリングが発生してしまうようです。(どうやら配列のsnapshotに対するmapはsnapshotの全要素にアクセスしているとみなされるようです)
const ParentComponent = () => {
const snapshot = useSnapshot(arrayState);
return snapshot.map((s) => (<ChildComponent snapshot={s} index={index} />));
}
const ChildComponent = (snapshot: {a: boolean}, index: number) => {
// ボタンをクリックして配列の中の1つの要素を更新すると、
// 親コンポーネントと、全ての子コンポーネントが再レンダリングされてしまう。
return <button onClick={() => (arrayState[index].a = !arrayState[index].a)}>{`${snapshot.a}`}</button>;
}
これを回避する方法を2つほど発見したのですが、
子コンポーネントにはsnapshotではなくproxyを渡し、親コンポーネントが再レンダリングされても子コンポーネントが再レンダリングされないように、子コンポーネントをメモ化する
方法が良さそうでした。
const ParentComponent = () => {
const snapshot = useSnapshot(arrayState);
// snapshotでmapして、子コンポーネントにはsnapshotではなくproxyを渡す
return snapshot.map((_, index) => (<ChildComponent proxy={arrayState[index]} />));
}
const ChildComponent = memo(function ChildComponent({proxy}: {proxy: {a: boolean}}) {
const snapshot = useSnapshot(proxy);
return <button onClick={() => (proxy.a = !proxy.a)}>{`${snapshot.a}`}</button>;
});
子要素の一部を編集すると、親コンポーネント自体の再レンダリングは発生してしまうのですが、子コンポーネントをメモ化することで、編集された子要素のコンポーネント以外は再レンダリングを防ぐことができます。
子コンポーネント全体の再レンダリングを防ぐもう一つの方法(一部正常に動作しないケースあり)
snapshotに対してmapするのではなく、snapshotのlengthのrangeに対してmapすると1つの子要素を編集しても全体の再レンダリングは発生しませんでした。
const ParentComponent = () => {
const snapshot = useSnapshot(arrayState);
// snapshot.mapではなくrange(snapshot.length).map
return range(snapshot.length).map((_, index) => (<ChildComponent proxy={arrayState[index] />));
}
const ChildComponent = (proxy: {a: boolean}) => {
const snapshot = useSnapshot(proxy)
return <button onClick={() => (proxy.a = !proxy.a)}>{`${snapshot.a}`}</button>;
}
ですが、この方法だと配列の要素のswapを実行した時に再レンダリングが発生しません。なのでswapを実行したい場合この方法だとダメです。逆にswapしない場合は子コンポーネントのメモ化が不要で手軽です。
結論
valtioはsnapshotのアクセスされているプロパティを見て、そのプロパティが更新された時だけ再レンダリングを発生させているようです。
無駄なレンダリングを引き起こさないためには、基本的にsnapshotはsnapshotを参照するコンポーネント内で取得するのが良いです。(snapshotをpropsでやり取りしない)
配列のsnapshotを取る場合は不要な再レンダリングが発生しやすいので注意が必要です。
Discussion
そう、そうなんです。直感に反する場合もあるのですが、この方が自然なのです。