13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ReactAdvent Calendar 2020

Day 6

【React】Hooks(関数コンポーネント)の挙動を検証しながら確認する回

Last updated at Posted at 2020-12-05

Hooks の挙動を具体的に説明できる自信がない...

みなさん、 useEffect とか useState とか、なんとなく使っていませんか?

stateの更新後に値が反映されるタイミングやuseEffect が再実行されるタイミングを具体的に説明できますか?

自分はこの辺があやふやだったので、調べたり検証したりしてみました。

関数コンポーネントのライフサイクル

関数コンポーネント+Hooksのライフサイクル は、こちらの サイトに載っている図を見ていただけたらなんとなく理解できると思います。

コンポーネントにはざっくり分けてマウントと更新とアンマウントのライフサイクルがあって、それぞれのタイミングで処理が実行されているってことがわかりますね。

更新やアンマウントが走るタイミングなどについては後ほど触れます。

再レンダリングとDOMの更新の挙動

※ この記事ではコンポーネントの関数が実行されるタイミングを「レンダリング」、レンダリング後にDOMの差分のみをブラウザに反映させることを「DOMの更新」と表現します。

まずReactのレンダリングの基本について確認しておきます。

コンポーネントの再レンダリングが発生するタイミングは、useStateuseReducerによってstateが変化した時とreact-domReactDOM.renderの実行時です。コンポーネント更新時にreturnの仮想DOMの差分が発生した場合にDOMの更新が発生します。

例えば、こんな感じのシンプルなカウンターがあったとすると、

App.jsx
// 記事内では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更新が発生している部分)

ezgif-2-2dd3829f9241.gif

これは具体的にどういう挙動かというと、

  1. setInterval 内の setCount が実行される(stateが更新されたので、この時点でコンポーネントの再レンダリングが確定する)
  2. setInterval のコールバック関数が終了したタイミングで App コンポーネントの再レンダリングが開始される(App関数が再実行される)
  3. useState部分のcount が新しい値に置き換わる
  4. return でJSXが確定する
  5. JSXの差分をとって、ブラウザにDOMが反映される

となっています。実際に処理を追ってみると、かなり分かりやすいと思います。

なので、このようにコンポーネントの再レンダーがかかる前にstateを使ってしまうと思わぬバグを生む可能性があるので注意しましょう。

App.jsx
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>
  );
}

同じ値をセットしたはずが...となります。

スクリーンショット 2020-12-06 7.12.04.png

次は、レンダリングは走り続けるが表示部が変化しない場合をみてみましょう。

App.jsx
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>
スクリーンショット 2020-12-06 2.55.30.png

JSXに固定値のみを組み込んだ例(<div>{"固定値"}</div>)
スクリーンショット 2020-12-06 2.56.14.png

どちらも コンポーネント の再レンダリングは発生していますが、DOMの差分が無いとDOMの更新が省略されることがわかりました。仮想DOM頼もしい!

再レンダリング時の子コンポーネントの挙動

再レンダリングされた際は、原則、子のコンポーネントも全て再レンダーが走ります。propsの変化とか関係なしに、無条件で実行されます。

以下は、全ての子コンポーネントが再レンダリングされている例です。

App.jsx
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} />
    </>
  );
}
ChildComponent.jsx
const ChildComponent = ({ number }) => {
  console.log(`Child ${number}`);
  return <div>Component {number}</div>;
};

スクリーンショット 2020-12-06 6.19.48.png

propsが変化している訳でもないのにしっかり再レンダーされているようですが、これは正しい挙動です。ただ、アンマウントされない限りはマウント処理が再度実行されることはありません。

挙動を確認するためにマウントとアンマウントと繰り返すような動きを見てみましょう。上記の処理を少し書き換えます。

App.jsx
<ChildComponent number={1} />
{count % 2 === 0 && <ChildComponent number={2} />}
<ChildComponent number={3} />
ChildComponent.jsx
const ChildComponent = ({ number }) => {
  useEffect(() => {
    console.log(`Child ${number} Mounted`);
  }, []);
  return <div>Component {number}</div>;
};

当然ですが、アンマウントされたコンポーネントだけがマウント処理を実行することがわかります。

ezgif-7-9c929174a9d7.gif

パフォーマンス上、子コンポーネントのレンダリングを省略したいシーン(例えば高頻度で更新処理が発生するなど)においては、React.memo, useCallback たちの出番です。(今回はやりません)

useEffectの挙動...🤔

まず、useEffectの基本動作からおさらいします。

コンポーネント 内で定義された useEffect はいずれもマウント直後のレンダリング後に実行され、
アンマウント時にクリーンアップ関数を実行します。useEffect(func)useEffect(func, [])useEffect(func, [deps, ...]) も、この動作は変わりません。

  1. useEffect(func) は、コンポーネント更新後に毎度実行されます。
  2. useEffect(func, [deps, ...]) は、コンポーネント更新後にdeps(依存変数)の値が更新前と異なっていた場合に実行されます。
  3. useEffect(func, []) は マウント直後以降は呼び出されることなく、さよならします。2のdepsが指定されないので依存変数が更新されることがなく呼び出されないイメージです。

では、こちらのプログラムはどういう挙動になるでしょうか。(真似しないでください)

App.jsx
function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setCount((c) => c + 1);
  }, [count]);
  return <div>{count}</div>;
}

当然、CPUの許す限りループし続けます。

ezgif-6-bb31337bd79b.gif

これはどういう挙動かというと、

  1. マウント時にuseEffectの第一引数が実行
  2. stateが変更される
  3. stateが変更されたので、コンポーネントが再レンダリングされる(再実行)
  4. countが変更されているので、再びuseEffectの第一引数が実行される
  5. 以下無限ループ

一見すると思わぬところで起こしてしまいそうですが、普通に実装していたらなかなか発生しないので安心してください。挙動を理解するためにあえて扱ってみただけです。

おわりに

あまり時間をかけれなかったので、甘い部分があるかもしれません。ご指摘お願いします。

余裕があったら useRef, useMemo, useCallback もやるかもしれません。(useMemouseCallbackは今回の内容と重複しそうですが)

13
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?