Hooks の挙動を具体的に説明できる自信がない...
みなさん、 useEffect
とか useState
とか、なんとなく使っていませんか?
stateの更新後に値が反映されるタイミングやuseEffect
が再実行されるタイミングを具体的に説明できますか?
自分はこの辺があやふやだったので、調べたり検証したりしてみました。
関数コンポーネントのライフサイクル
関数コンポーネント+Hooksのライフサイクル は、こちらの サイトに載っている図を見ていただけたらなんとなく理解できると思います。
コンポーネントにはざっくり分けてマウントと更新とアンマウントのライフサイクルがあって、それぞれのタイミングで処理が実行されているってことがわかりますね。
更新やアンマウントが走るタイミングなどについては後ほど触れます。
再レンダリングとDOMの更新の挙動
※ この記事ではコンポーネントの関数が実行されるタイミングを「レンダリング」、レンダリング後にDOMの差分のみをブラウザに反映させることを「DOMの更新」と表現します。
まずReactのレンダリングの基本について確認しておきます。
コンポーネントの再レンダリングが発生するタイミングは、useState
やuseReducer
によってstateが変化した時とreact-dom
のReactDOM.render
の実行時です。コンポーネント更新時にreturnの仮想DOMの差分が発生した場合にDOMの更新が発生します。
例えば、こんな感じのシンプルなカウンターがあったとすると、
// 記事内ではimport, exportを省略します
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => setCount((c) => c + 1), 100);
}, []);
return (
<div>
count: {count}
<div>count x20: {count * 20}</div>
</div>
);
}
Chromeデベロッパーツールで確認するとこうなります。(紫色の部分がDOM更新が発生している部分)
これは具体的にどういう挙動かというと、
-
setInterval
内のsetCount
が実行される(stateが更新されたので、この時点でコンポーネントの再レンダリングが確定する) -
setInterval
のコールバック関数が終了したタイミングでApp
コンポーネントの再レンダリングが開始される(App
関数が再実行される) -
useState
部分のcount
が新しい値に置き換わる -
return
でJSXが確定する - JSXの差分をとって、ブラウザにDOMが反映される
となっています。実際に処理を追ってみると、かなり分かりやすいと思います。
なので、このようにコンポーネントの再レンダーがかかる前にstateを使ってしまうと思わぬバグを生む可能性があるので注意しましょう。
function App() {
const [count, setCount] = useState(0);
const [backupCount, setBackupCount] = useState(count);
return (
<div
onClick={() => {
setCount((c) => c + 1);
setBackupCount(count); // まだcountの更新が反映されていない... バグの原因
console.log(count, backupCount); // 差が生じる
}}
>
count: {count}
<br />
backupCount: {backupCount}
</div>
);
}
同じ値をセットしたはずが...となります。
次は、レンダリングは走り続けるが表示部が変化しない場合をみてみましょう。
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => setCount((c) => c + 1), 100);
}, []);
return <div>{"固定値"}</div>;
}
当然、DOMは更新されません。以下、JSXにstateを組み込んだ例と、JSXに固定値のみを組み込んだ例です。(Chromeのデベロッパーツールより)
JSXにstateを組み込んだ例(<div>{state}</div>
)
JSXに固定値のみを組み込んだ例(<div>{"固定値"}</div>
)
どちらも コンポーネント の再レンダリングは発生していますが、DOMの差分が無いとDOMの更新が省略されることがわかりました。仮想DOM頼もしい!
再レンダリング時の子コンポーネントの挙動
再レンダリングされた際は、原則、子のコンポーネントも全て再レンダーが走ります。propsの変化とか関係なしに、無条件で実行されます。
以下は、全ての子コンポーネントが再レンダリングされている例です。
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => setCount((c) => c + 1), 1000);
}, []);
return (
<>
{/* Appコンポーネント更新時に、全て更新されます。 */}
<ChildComponent number={1} />
<ChildComponent number={2} />
<ChildComponent number={3} />
</>
);
}
const ChildComponent = ({ number }) => {
console.log(`Child ${number}`);
return <div>Component {number}</div>;
};
propsが変化している訳でもないのにしっかり再レンダーされているようですが、これは正しい挙動です。ただ、アンマウントされない限りはマウント処理が再度実行されることはありません。
挙動を確認するためにマウントとアンマウントと繰り返すような動きを見てみましょう。上記の処理を少し書き換えます。
<ChildComponent number={1} />
{count % 2 === 0 && <ChildComponent number={2} />}
<ChildComponent number={3} />
const ChildComponent = ({ number }) => {
useEffect(() => {
console.log(`Child ${number} Mounted`);
}, []);
return <div>Component {number}</div>;
};
当然ですが、アンマウントされたコンポーネントだけがマウント処理を実行することがわかります。
パフォーマンス上、子コンポーネントのレンダリングを省略したいシーン(例えば高頻度で更新処理が発生するなど)においては、React.memo
, useCallback
たちの出番です。(今回はやりません)
useEffectの挙動...🤔
まず、useEffect
の基本動作からおさらいします。
コンポーネント 内で定義された useEffect
はいずれもマウント直後のレンダリング後に実行され、
アンマウント時にクリーンアップ関数を実行します。useEffect(func)
も useEffect(func, [])
も useEffect(func, [deps, ...])
も、この動作は変わりません。
-
useEffect(func)
は、コンポーネント更新後に毎度実行されます。 -
useEffect(func, [deps, ...])
は、コンポーネント更新後にdeps
(依存変数)の値が更新前と異なっていた場合に実行されます。 -
useEffect(func, [])
は マウント直後以降は呼び出されることなく、さよならします。2のdeps
が指定されないので依存変数が更新されることがなく呼び出されないイメージです。
では、こちらのプログラムはどういう挙動になるでしょうか。(真似しないでください)
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount((c) => c + 1);
}, [count]);
return <div>{count}</div>;
}
当然、CPUの許す限りループし続けます。
これはどういう挙動かというと、
- マウント時に
useEffect
の第一引数が実行 - stateが変更される
- stateが変更されたので、コンポーネントが再レンダリングされる(再実行)
-
count
が変更されているので、再びuseEffect
の第一引数が実行される - 以下無限ループ
一見すると思わぬところで起こしてしまいそうですが、普通に実装していたらなかなか発生しないので安心してください。挙動を理解するためにあえて扱ってみただけです。
おわりに
あまり時間をかけれなかったので、甘い部分があるかもしれません。ご指摘お願いします。
余裕があったら useRef
, useMemo
, useCallback
もやるかもしれません。(useMemo
と useCallback
は今回の内容と重複しそうですが)