2019.12.31

関数型Reactコンポーネントでレンダリングと副作用Hookが実行されるタイミング


この記事では、いくつかの簡単なサンプルを通して、関数型の 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)

コードの流れを追うと、以下のように説明できるでしょう。

  1. 最初に MyComponent が実行される。→ # (1)
  2. コンポーネントが DOM にレンダリングされる。
  3. useEffect が呼ばれる。→ # (2)
  4. useEffect 内で setName が呼ばれ、コンポーネントの内部状態が変化する(name = "John")。
  5. もう一度、MyComponent が実行される。→ # (3)
  6. コンポーネントが DOM にレンダリングされる。
  7. useEffect が呼ばれる。→ # (4)
  8. useEffect 内で setName が呼ばれる(今回も name = "John")。
  9. さらに MyComponent が実行される。→ # (5)
  10. 前回の実行時(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)

コードの流れを追うと、以下のように説明できるでしょう。

  1. 最初に MyComponent が実行される。→ # (1)
  2. コンポーネントが DOM にレンダリングされる。
  3. useEffect が呼ばれる。→ # (2)
  4. useEffect 内で setName が呼ばれ、コンポーネントの内部状態が変化する(name = "John")。
  5. もう一度、MyComponent が実行される。→ # (3)
  6. コンポーネントが DOM にレンダリングされる。
  7. 第二引数に空配列が渡されているため、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)

コードの流れを追うと、以下のように説明できるでしょう。

  1. イベントハンドラで setNumB が呼ばれ、コンポーネントの内部状態が変化する。
  2. App が実行される。→ # (1)
  3. コンポーネントが DOM にレンダリングされる。
  4. useEffect が呼ばれる。→ # (2)
  5. useEffect 内で setResult が呼ばれる。
  6. さらに MyComponent が実行される。→ # (3)
  7. 前回の実行時(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 が利用できます。

ただし、常に useCallbackuseMemo を使った最適化を行うべき、というわけでもありません。最適化にも処理コストはかかりますし、そもそも React のレンダリング処理は十分速いので、大抵の場合は複雑なレンダリングパターンを考慮して最適化コードを書いても、少ない費用対効果しか得られないかもしれません。このテーマについては、こちらの記事:When to useMemo and useCallback が参考になります。


以上、関数型 React コンポーネントのレンダリングと Hook の実行タイミングについて検証しました。

この辺の挙動は、動作する React コードがかけるようになったあとでもなんとなく難しく感じられるでしょう(私がそうです)から、理解の一助となれば幸いです。