この記事では、いくつかの簡単なサンプルを通して、関数型の React コンポーネントにおいて、レンダリングと副作用 Hook(useEffect
)がいつ実行されるのかを検証してみます。
関数型コンポーネントのレンダリング
先に検証から得られたルールを要約すると、以下の通りです。
- 内部状態またはプロパティが変更されると、コンポーネントの関数が再実行される。
- 関数の結果が前回の呼び出し時と同じであれば、レンダリングは発生しない。
- レンダリングが完了すると、
useEffect
が実行される。
3番目に関して、レンダリング完了後に必ず useEffect
が呼ばれるのか、一回だけ呼ばれるのか、特定の内部状態をチェックして変化があったときのみ呼ばれるのか、その挙動は useEffect
の第二引数に依存します。
これらのルールを理解すれば、ほとんどのケースでレンダリングと副作用 Hook の挙動を予測できるようになるでしょう。
では、ここから検証コードの紹介です。
useEffect
まずは以下のコンポーネントがあるとします。
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
function MyComponent() {
const [name, setName] = useState('');
console.log('MyComponent');
setName('John');
return <h1>Hello, {name}</h1>
}
ReactDOM.render(<MyComponent />, document.querySelector('#root'));
副作用 Hook が用いられず、コンポーネントの直下で内部状態が変更(setName
)されています。
これは無限ループを引き起こします。なぜなら、内部状態の変更によって関数 MyComponent
の再実行され、その中でまた内部状態が変更され...というループに陥るからです。
このコードに副作用 Hook を導入します。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function MyComponent() {
console.log('MyComponent');
const [name, setName] = useState('');
useEffect(() => {
console.log('useEffect');
setName('John');
});
return <h1>Hello, {name}</h1>
}
ReactDOM.render(<MyComponent />, document.querySelector('#root'));
useEffect
の第二引数には何も渡しません。これは useEffect
に登録された関数が、レンダリングが終わるたびに呼び出されることを指示します。
このコードは、以下のコンソールログを出力します。
MyComponent # (1)
useEffect # (2)
MyComponent # (3)
useEffect # (4)
MyComponent # (5)
コードの流れを追うと、以下のように説明できるでしょう。
- 最初に
MyComponent
が実行される。→ # (1) - コンポーネントが DOM にレンダリングされる。
useEffect
が呼ばれる。→ # (2)useEffect
内でsetName
が呼ばれ、コンポーネントの内部状態が変化する(name = "John")。- もう一度、
MyComponent
が実行される。→ # (3) - コンポーネントが DOM にレンダリングされる。
useEffect
が呼ばれる。→ # (4)useEffect
内でsetName
が呼ばれる(今回も name = "John")。- さらに
MyComponent
が実行される。→ # (5) - 前回の実行時(5.)と返却される JSX の値が同じため、差分検出処理の結果、レンダリングは実行されない。
上記 10. のあとは、useEffect
に登録された関数は呼ばれません。レンダリングが発生していないからです。その証拠に、以下のように useEffect
を書き換えると、無限ループが得られます。
useEffect(() => {
console.log('useEffect');
setName('John' + (new Date()).getTime());
});
useEffect
が実行されるたびに状態が変わり、関数型コンポーネントが返却する JSX が変化するので、毎回レンダリングが発生 → useEffect
→ レンダリング...のループが発生します。
では次に、useEffect
に第二引数を与えてみましょう。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function MyComponent() {
console.log('MyComponent');
const [name, setName] = useState('');
useEffect(() => {
console.log('useEffect');
setName('John');
}, []);
return <h1>Hello, {name}</h1>;
}
ReactDOM.render(<MyComponent />, document.querySelector('#root'));
このコードは、以下のコンソールログを出力します。
MyComponent # (1)
useEffect # (2)
MyComponent # (3)
コードの流れを追うと、以下のように説明できるでしょう。
- 最初に
MyComponent
が実行される。→ # (1) - コンポーネントが DOM にレンダリングされる。
useEffect
が呼ばれる。→ # (2)useEffect
内でsetName
が呼ばれ、コンポーネントの内部状態が変化する(name = "John")。- もう一度、
MyComponent
が実行される。→ # (3) - コンポーネントが DOM にレンダリングされる。
- 第二引数に空配列が渡されているため、
useEffect
は2度は呼ばれない。
ここまでで、レンダリングと副作用 Hook の実行タイミングがだんだん見えてきました。続いて、useEffect
の第二引数に値の入った配列を与えるパターンを再現するために、以下のコンポーネントについて考えましょう。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function App() {
console.log('App');
const [numA, setNumA] = useState(1);
const [numB, setNumB] = useState(1);
const [result, setResult] = useState(0);
useEffect(() => {
console.log('useEffect');
setResult(numA * 2);
});
return (
<>
<h1>numA * 2 = {result}</h1>
<button onClick={() => setNumA(numA + 1)}>{numA}</button>
<button onClick={() => setNumB(numB + 1)}>{numB}</button>
</>
);
}
ReactDOM.render(<App />, document.querySelector('#root'));
このコードは、以下のコンソールログを出力します。
App
useEffect
App
useEffect
App
この出力結果は、上記の2つ目の例に酷似しています。コードの流れも同様と考えられます。
一つ目のボタンを押すと、初期表示と同じコンソールログが出力されるため、再度同じ流れが実行されているようです。
二つ目のボタンを押す(numB
が増える)と、以下の出力が得られます。
App # (1)
useEffect # (2)
App # (3)
コードの流れを追うと、以下のように説明できるでしょう。
- イベントハンドラで
setNumB
が呼ばれ、コンポーネントの内部状態が変化する。 App
が実行される。→ # (1)- コンポーネントが DOM にレンダリングされる。
useEffect
が呼ばれる。→ # (2)useEffect
内でsetResult
が呼ばれる。- さらに
MyComponent
が実行される。→ # (3) - 前回の実行時(2.)と返却される JSX の値が同じため、レンダリングは実行されない。
numB
が変化するタイミングでは useEffect
の登録内容は再計算される必要はありませんが、第二引数を与えていないため、いずれにせよ呼ばれていることが分かります。
次に、useEffect
の第二引数に空配列を与えてみます。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function App() {
console.log('App');
const [numA, setNumA] = useState(1);
const [numB, setNumB] = useState(1);
const [result, setResult] = useState(0);
useEffect(() => {
console.log('useEffect');
setResult(numA * 2);
}, []);
return (
<>
<h1>numA * 2 = {result}</h1>
<button onClick={() => setNumA(numA + 1)}>{numA}</button>
<button onClick={() => setNumB(numB + 1)}>{numB}</button>
</>
);
}
ReactDOM.render(<App />, document.querySelector('#root'));
このコードは、以下のコンソールログを出力します。
App
useEffect
App
今度は上記の3つ目の例の出力結果に酷似しています。コードの流れも同様でしょう。
ただ、一つ目のボタンを押しても以下の出力が得られるだけで result
は増えません。
App
初回の一度だけ呼ばれるよう指示しているので、num
が変更されても useEffect
が実行されないためです。ちなみに二つ目のボタンを押しても挙動は同じです。
そこで、useEffect
の第二引数の配列に、numA
を指定します。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function App() {
console.log('App');
const [numA, setNumA] = useState(1);
const [numB, setNumB] = useState(1);
const [result, setResult] = useState(0);
useEffect(() => {
console.log('useEffect');
setResult(numA * 2);
}, [numA]);
return (
<>
<h1>numA * 2 = {result}</h1>
<button onClick={() => setNumA(numA + 1)}>{numA}</button>
<button onClick={() => setNumB(numB + 1)}>{numB}</button>
</>
);
}
ReactDOM.render(<App />, document.querySelector('#root'));
このコードは、以下のコンソールログを出力します。
App
useEffect
App
ここまでは、空配列を与えた場合と同じです。
一つ目のボタンを押すと、上記と同じログを出力して result
が更新されます。二つ目のボタンを押すと、以下のログのみを出力します。
App
useEffect
が、numA
の変化時にしか呼ばれない=計算タイミングを最適化できていることが確認できました。
コールバックを props として渡す
ここまでで内部状態が変更したときの挙動を見てきました。プロパティが変化するパターンでは、どのような動きになるでしょうか。特に関数をプロパティとして渡したときの挙動が面白いので、紹介します。
以下のコンポーネントについて考えます。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function Child({ onClick }) {
useEffect(() => {
console.log('Child');
}, [onClick]);
return <button onClick={onClick}>push</button>;
}
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => console.log('onClick');
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child onClick={handleClick} />
</>
);
}
ReactDOM.render(<Parent />, document.querySelector('#root'));
Parent
コンポーネントのボタンをクリックすると、内部状態が変更されるので、Parent
は再実行されるのですが、Child
のほうはどうなるか?がここでのポイントです。
このコードでは、Parent
コンポーネントのボタンをクリックするたびに、以下の出力が得られます。
Child
つまり、Parent
が再実行されるたびに、Child
も再実行され、そのうえレンダリングまで実行されています。
以下のように、onClick
プロパティが変化しない限りは "Child"
というログは出力されないはずです。Parent
が再実行されたとしても、handleClick
関数の内容は変わっていないはずなのに、なぜでしょう?
useEffect(() => {
console.log('Child');
}, [onClick]);
実は、アロー関数として変数に代入された関数は、内容が同じであっても生成されるたびに違うものと判定されます。
関数だけではなく、以下のようにオブジェクトでも、配列でも挙動は同じになります。
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
function Child({ obj }) {
useEffect(() => {
console.log('Child');
}, [obj]);
return <p>{obj.name}</p>;
}
function Parent() {
const [count, setCount] = useState(0);
const user = { name: 'John' };
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child obj={user} />
</>
);
}
ReactDOM.render(<Parent />, document.querySelector('#root'));
この挙動は、JavaScript のデータ型についての言語仕様に関係しています。
文字列や数値、真偽値を変数に代入すれば、変数には値そのものが入ります。これに対して、関数や配列、オブジェクトの場合は、変数に入るのはメモリ上の位置を示す識別子です。
内容が同じであっても生成されるたびにメモリ上の位置は異なるため、「違うもの」と判定されます。
子コンポーネントを必要なく再レンダリングすることを避ける方法は、いくつかあります。
まず、(そんなことはあまりないと思いますが、)コールバックの内容がコンポーネントに依存していないのであれば、コンポーネントの外に出してやればよいでしょう。
const handleClick = () => console.log('onClick');
function Parent() {
// 中略...
}
こうすれば、Parent
が再実行されても、handleClick
が再生成されることはありません。
別の方法は、useCallback を使うことです。
import React, { useState, useEffect, useCallback } from 'react';
// 中略...
function Parent() {
// 中略...
const handleClick = useCallback(() => console.log('onClick'), []);
// 中略...
}
useCallback
に登録した関数は、常に新しく生成されるわけではなくなります。第二引数に渡した依存関係が変化していなければ、前回と全く同じ関数を返却します。そのため、場合により子コンポーネントの再実行を減らすことができます。
const handleClick = useCallback(() => {
// a と b と c を使った計算処理
}, [a, b, c]); // たとえば d や e が変化したとしても、handleClick は変化しない
この第二引数の挙動は useEffect
と同様です。さらに、関数の場合は useCallback
ですが、オブジェクトや配列の場合は同様の useMemo
が利用できます。
ただし、常に useCallback
や useMemo
を使った最適化を行うべき、というわけでもありません。最適化にも処理コストはかかりますし、そもそも React のレンダリング処理は十分速いので、大抵の場合は複雑なレンダリングパターンを考慮して最適化コードを書いても、少ない費用対効果しか得られないかもしれません。このテーマについては、こちらの記事:When to useMemo and useCallback が参考になります。
以上、関数型 React コンポーネントのレンダリングと Hook の実行タイミングについて検証しました。
この辺の挙動は、動作する React コードがかけるようになったあとでもなんとなく難しく感じられるでしょう(私がそうです)から、理解の一助となれば幸いです。