はじめに
React のコンポーネントのレンダリングの条件とどのように不必要な再レンダリングを防ぐかについて、手元で動かして確認しながらまとめました。
誤り等ありましたらコメントでご指摘いただけますと幸いです
レンダリングの条件
まず、再レンダリングとはどのようなことを指すのでしょうか。
コンポーネントは以下のような流れで画面上に表示、更新されます。
レンダリング -> マウント -> 【state の更新など何かしらのイベント】 -> 再レンダリング -> 仮想DOMの差分を検知 -> リアルDOMに差分を反映 -> ... -> アンマウント
一度 DOM にマウントされたコンポーネントに state の更新などが生じると、React はコンポーネントを再度レンダリングします。state の更新以外にも、親コンポーネントがレンダリングされた場合や渡される props が更新された場合などもレンダリングが走ります。
state の更新によるレンダリング
useState のセッター関数が呼ばれたときなど、state が更新されることによりレンダリングが走ります。
例えば、以下のようなコンポーネント。
const App = () => {
console.log("Appレンダリング!")
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount((count) => count + 1);
}
return (
<>
<div>
{count}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
</>
);
}
CountUp
ボタンを押すたびにこのコンポーネントのレンダリングが走ります。この再レンダリングは描画の変更の有無に関わらず実行されるので、例えば {count}
をdivタグごと削除したとしても CountUp
ボタンを押した時にレンダリング処理が走ります。
親コンポーネントのレンダリングによるレンダリング
親コンポーネントがレンダリングされると、渡される props の変更の有無に関わらずレンダリングが走ります。つまりコンポーネントツリーのルートコンポーネントがレンダリングした場合、その子コンポーネント、孫コンポーネントと連鎖的にレンダリング処理が走ります。
以下のように親コンポーネントにあたる App コンポーネントから子コンポーネントにあたる Child コンポーネントを呼んでみます。
const Child = () => {
console.log("Childレンダリング!")
return (
<div>
Childコンポーネント!
</div>
);
}
const App = () => {
console.log("Appレンダリング!")
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount((count) => count + 1);
}
return (
<>
<div>
{count}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
<Child />
</>
);
}
親コンポーネントにあたる App コンポーネントにレンダリングが走ると、子コンポーネントである Childコンポーネントにもレンダリング処理が走ります。例え props に変更がなくても(props 自体がなくても)子コンポーネントは再レンダリングします。
再レンダリングの防止方法
レンダリングコストの高いコンポーネントの場合、不必要な再レンダリングをメモ化で未然に防ぐことによりパフォーマンスの向上が見込めます。
(メモ化自体がコストのかかる処理であるため、必要以上にメモ化で再レンダリングを防ごうとすると返ってパフォーマンスが悪くなる可能性もあります。)
React.memo による再レンダリング防止
React.memo()
関数にコンポーネントを渡すことにより、props の変更の有無を確認して再レンダリングが制御されるラッパーコンポーネントが返されます。
const Child = React.memo(({message}) => {
console.log("Childレンダリング!")
return (
<div>
{message}
</div>
);
})
const App = () => {
console.log("Appレンダリング!")
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount((count) => count + 1);
}
return (
<>
<div>
{count}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
<Child message={"Childコンポーネント!"}/>
</>
);
}
上のように React.memo()
で Child コンポーネントをラップすることにより、親コンポーネントのレンダリング による再レンダリングを防ぐことができます。
(props として count
を渡すようにすると、ボタンクリックの度に渡される props の値が変わるので、ちゃんと Child コンポーネントも再レンダリングされるようになります。)
ここで、props として渡している値が関数であった場合では React.memo()
のみの制御ではうまくいかなくなります。なぜなら、関数はレンダリングごとに別参照のオブジェクトとして生成されるため、props が等価でないと判断されるためです。
const Child = React.memo(({action}) => {
console.log("Childレンダリング!")
return (
<button onClick={action}>
Action!
</button>
);
})
const App = () => {
console.log("Appレンダリング!")
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount((count) => count + 1);
}
const sayHello = () => {
console.log('Hello!');
}
return (
<>
<div>
{count}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
<Child action={sayHello}/>
</>
);
}
CountUp!
ボタンを押して App コンポーネントが再レンダリングする度に新しい参照の sayHello
関数が渡され、Child コンポーネントのレンダリングも走ります。
このような場合は useCallback
の使用を検討します。
useCallback による再レンダリング防止
useCallback
に関数を渡すことにより、メモ化をおこなってくれます。また第二引数に依存配列を渡すことによりそのメモ化をコントロールすることができます。今回はただの文字列をコンソール出力する関数なので依存配列は空配列とします。
const Child = React.memo(({action}) => {
console.log("Childレンダリング!")
return (
<button onClick={action}>
Action!
</button>
);
})
const App = () => {
console.log("Appレンダリング!")
const [count, setCount] = useState(0);
const incrementCount = () => {
setCount((count) => count + 1);
}
const sayHello = useCallback(() => {
console.log('Hello!');
}, [])
return (
<>
<div>
{count}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
<Child action={sayHello}/>
</>
);
}
このようにして関数をメモ化することにより、CountUp!
ボタンを押すたびに Child コンポーネントが再レンダリングされるのを防ぐことができます。
また、渡す props をメモ化しても Child コンポーネント自体がメモ化されていなかったら普通に再レンダリングが走ってしまうので注意が必要です。(親コンポーネントがレンダリングされていることに変わりはないため)
最後に、もう一捻り加えてみます。
message
という state を追加して、count
を2倍した値を使って message
を更新する関数を props として渡してみます。依存配列には count
を入れる必要があります。( count
を依存に設定しないと、いくら CountUp!
ボタンをクリックしても Action!
ボタンをクリックしたときの出力が Double : 0
のままになってしまいます)
const Child = React.memo(({action}) => {
console.log("Childレンダリング!")
return (
<button onClick={action}>
Action!
</button>
);
})
const App = () => {
console.log("Appレンダリング!")
const [count, setCount] = useState(0);
const [message, setMessage] = useState("initialized...");
const incrementCount = () => {
setCount((count) => count + 1);
}
const updateMessage = useCallback(() => {
setMessage(() => `Double : ${count * 2}`);
}, [count])
return (
<>
<div>
{count}
</div>
<div>
{message}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
<Child action={updateMessage}/>
</>
);
}
CountUp!
ボタンを押すたびに updateMessage
関数が新しい count
に基づいて作られるため、どうしても Child コンポーネントのレンダリングが走ってしまいます。
props として渡している関数が state に依存しているのが再レンダリングの原因であり、このような場合に Child コンポーネントの再レンダリングを避けたいときは、useReducer
により「 state に非依存な関数」を props として渡すようにする方法があります。
useReducer での state 管理
使い方については省略しますが、useReducer
は useState
のような state を管理するためのフックのひとつ(というか、useState
は内部的に useReducer
を使っています。)です。重要なのは、今回の場合 useReducer
を使用することにより dispatch
という state に依存していない関数を props として渡すことができるようになる、ということです。updateMessage
関数の中で count
を参照していたような処理は reducer の中に閉じ込めることができます。
まずは、reducer を下のように作成します。
const initialState = {
count: 0,
message: "initialized..."
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'count/increment':
return {
...state,
count: state.count + 1
}
case 'message/update':
return {
...state,
message: `Double : ${state.count * 2}`
}
default:
return state;
}
};
count
と message
をシングルツリーの state
オブジェクトの中にまとめることにより、「既存の state を受け取って新しい state オブジェクトを返す」というピュアな reducer 動きの中に処理を押し込むことができます。
あとは、しかるべき action を dispatch する関数をコンポーネントに配置すればよいだけなので、以下のように依存のない関数を porps として渡すことができるようになります。
const Child = React.memo(({action}) => {
console.log("Childレンダリング!")
return (
<button onClick={action}>
Action!
</button>
);
})
const App = () => {
console.log("Appレンダリング!")
const [{ count, message }, dispatch] = useReducer(reducer, initialState);
const incrementCount = () => {
dispatch({ type: 'count/increment' });
}
const updateMessage = useCallback(() => {
dispatch({ type: 'message/update' });
}, [])
return (
<>
<div>
{count}
</div>
<div>
{message}
</div>
<button onClick={incrementCount}>
CountUp!
</button>
<Child action={updateMessage}/>
</>
);
}
これで、CountUp!
ボタンを押してもその度に Child コンポーネントがレンダリングされるのを防げるようになりました。 Action!
ボタンを押しても正常に動作します。
今回は dispatch
を呼ぶ場所を App コンポーネントにまとめていますが、<Child dispatch={dispatch}/>
のようにして Child コンポーネントから dispatch するやり方もあります。その場合、useCallback
のラップがなくなりすっきりしそうです。
まとめ
今回挙げたもの以外にも、useMemo
というメモ化された値を返すフックもあります。
繰り返しになりますが、メモ化自体にコストがかかるため、パフォーマンス計測などを通して適切にコンポーネントのレンダリングを制御することが大切だと思います!
参考
・https://qiita.com/hellokenta/items/6b795501a0a8921bb6b5
・https://qiita.com/soarflat/items/b9d3d17b8ab1f5dbfed2
・https://qiita.com/uhyo/items/cea1bd157453a85feebf