Michi's Tech Blog

一人前のWebエンジニアを目指して

Reactキャッチアップへの道① 『useStateとuseEffect』

こんにちは!
スマレジ テックファームのMichiです!

前回の記事『Webエンジニアへ転職して1年が経ちました』で書いた通り、Q1(5月~7月)は「React/TypeScriptの習得」を目指します。

その第一段階として、5月はReactの基礎を固めていきます。教材はUdemyのこちらのコースを使用しています。

https://www.udemy.com/course/modern_javascipt_react_beginner/www.udemy.com

https://www.udemy.com/course/react_stepup/www.udemy.com

今回は、React Hooksの基本的な機能であるuseStateuseEffectについてアウトプットしていきます。

React Hooksとは?

まず、「React Hooksとはなにか?」について解説します。

React HooksはReact 16.8以降に導入された機能で、クラスコンポーネントを使用せずに状態やライフサイクルメソッドなどの機能を関数コンポーネントで使用できるようにするためのものです。

従来のReactでは、状態を管理するためにクラスコンポーネントを使用する必要がありました。しかし、Hooksを使用することで、関数コンポーネントでも状態を管理できるようになりました。

これらのHooksを使うことで、クラスコンポーネントに依存しないよりシンプルなコードを書くことができます。初めてReactを触る人でも、Hooksを使うことでより簡単にReactアプリケーションを開発することができます。

実際に、React Hooksの登場によって、複雑なクラスコンポーネントを設計する必要がなくなり、Reactが爆発的に普及するきっかけとなったようです。

useStateとuseEffect

公式ドキュメントを参照すると、React18.2の時点でReactが公式に提供しているHooksは15個になります。今回紹介するuseStateuseEffectは、その中でもReactの機能の根幹をなすHooksです。

両者の役割を箇条書きすると、

となります。これだけ書いても訳がわからないと思うので、さっそく詳しい解説にいきましょう。

useState

useStateはReact Hooksの中でも最も基本的なHooksで、値の状態管理を行うために使います。

useStateの文法は以下の通りです。

const [状態, 更新関数] = useState(初期状態);

useState関数の引数には、初期状態として設定したい値を渡します。useStateは配列を返し、配列の最初の要素には現在の状態の値、二番目の要素には状態を変更するための関数が格納されます。返り値をそれぞれ得るためには、上記のように分割代入を使用します。

以下は、カウントアップボタンをuseStateを使って実装したコンポーネントの例です。

import React, { useState } from 'react';

export const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

この例では、countという変数を宣言し、その初期値としてuseState0を渡しています。次に、handleClick関数内でsetCountを呼び出して、count1を加算した結果を新しい値として設定しています。これが、最新のcountの値としてコンポーネント内で上書きされます。

最後に、JSXで現在のcountの値を表示するための要素を作成し、<button>要素をクリックした際にhandleClick関数を呼び出すように設定しています。

useStateは、数値、文字列、オブジェクト、配列などの任意の値を扱うことができます。また、useStateで定義した値は、状態を更新するたびにコンポーネントが再レンダリングされるという特徴があります。

useEffect

コンポーネントの再レンダリング

まず、useEffectの概念を正しく理解するために、Reactにおいてコンポーネントが再レンダリングされる条件を理解しておく必要があります。その条件とは、次の3つです。

  1. コンポーネント内の状態管理されている値(state)が変更されたとき
  2. コンポーネントからプロパティ(props)を受け取っている場合、そのプロパティ値が変更されたとき
  3. コンポーネントが再レンダリングされたとき

(例外はありますが、最初はとりあえずこの3つを覚えておけば大丈夫です。)

この中で、useEffectの機能にかかわってくるのは、1. コンポーネント内の状態管理されている値が変更されたときです。どういう意味か、サンプルコードを交えながら解説します。

レンダリングの無限ループ

先ほどのuseStateの解説で示したコードに、カウントが3の倍数の時だけ「3の倍数です」という文章を表示する、というロジックを追加します。

/* ① 読み込み開始位置 */
import React, { useState } from "react";

export const Counter = () => {
  const [count, setCount] = useState(0); 
  const [isShowMessage, setIsShowMessage] = useState(false);

  const handleClick = () => {
    setCount(count + 1);
  };

  /* ② countが3の倍数かの判定 */ 
  if (count % 3 === 0 && count !== 0) {
    setIsShowMessage(true);
  } else {
    setIsShowMessage(false);
  }
  /* ③ 状態の更新終了 → ①へ戻る */

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
      {isShowMessage && <p>3の倍数です</p>}
    </div>
  );
};

useStateを使い、メッセージを表示させるフラグisShowMessageと、その状態更新関数であるsetIsShowMessageを定義します。条件分岐で、カウントが3の倍数ならisShowMessagetrue、それ以外ならfalseで定義します。あとは、JSXでisShowMessagetrueのときだけ、<p>3の倍数です</p>の要素を表示するよう設定しています。

一見、この実装は正しいように見えますが、実際はエラーとなります。

何が問題なのでしょうか?
エラー文には、Too many re-renders. React limits the number of renders to prevent an infinite loop.(訳:再レンダリングが多すぎます。Reactは無限ループを防止するために、この無数のレンダリングを制限しました。)とあります。

これは、先ほど述べた、Reactにおいてコンポーネントが再レンダリングされる条件の「1. コンポーネント内の状態管理されている値が変更されたとき」によって引き起こされるものです。

どういうことかというと、まずこのコンポーネントが最初に表示されたとき、ファイルの一番上(①)から読み込みが始まります。

そして、②で「countが3の倍数であるかどうか」の判定を行います。②の判定に応じて、isShowMessageの値をtrue、もしくはfalseに更新するのですが、ここで先ほどの条件、コンポーネント内の状態管理されている値が変更されたとき」に当てはまります。そうすると、再度コンポーネントレンダリングが始まり、ファイルの一番上(①)に処理が戻ります。

このように、レンダリングの無限ループが発生しているため、この実装はエラーとなります。

useEffectで解決

ここまでで、コンポーネントの無限レンダリングが発生する原因は理解してもらえたと思います。では、どうすればこの無限レンダリングを解決できるでしょうか?

「すべての状態更新が終わった後でisShowMessageの値を決定し、その後はレンダリングを行わない」という処理ができれば解決できそうです。その機能を提供するのが、まさにuseEffectです。

useEffectの使い方は以下の通りです。

useEffect(() => {
  // 副作用(レンダリング後)の処理
}, [依存配列])

useEffect関数の第一引数には、副作用の処理を渡します。「副作用」という難しい言葉が出てきましたが、「コンポーネントレンダリングが終了した後に行いたい処理」と解釈すればOKです。

第二引数には、依存する値を配列形式で列挙します。今回は、変数countの値が変化したときだけフラグの判定を行いたいので、配列の中身はcountのみです。なお、依存配列を空([])で定義した場合は、初期レンダリング時のみ処理が実行されます。

実際に、useEffectをサンプルコード上で使用したものが以下のコンポーネントです。

import React, { useEffect, useState } from "react";

export const Counter = () => {
  const [count, setCount] = useState(0);
  const [isShowMessage, setIsShowMessage] = useState(false);

  const handleClick = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    if (count % 3 === 0 && count !== 0) {
      setIsShowMessage(true);
    } else {
      setIsShowMessage(false);
    }
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
      {isShowMessage && <p>3の倍数です</p>}
    </div>
  );
};

これで、レンダリングの無限ループを起こすことなく、3の倍数のときだけメッセージを表示させることができました。

追記(2023/05/09)

ブログ読んでくれた方から指摘をいただいたので追記します。

今回の例では、isShowMessageを状態管理可能な変数として定義し、useEffect内で値を決定しました。しかし、実際は次のようにuseEffectを使わずとも、直接isShowMessageを定義するだけで処理できます。

import React, { useState } from "react";

export const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  // 状態管理可能な値としてではなく、通常の変数として定義する
  const isShowMessage = count % 3 === 0 && count !== 0 ? true : false;

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
      {isShowMessage && <p>3の倍数です</p>}
    </div>
  );
};

なぜなら、countの値が変更されると再レンダリングが走り、コンポーネント内の1行目から再び処理が走るので、毎回isShowMessageの値を決定するロジックを通ることになるからです。

筆者は実務ではVue.jsを使用していますが、Vueではこのような場合、computedという関数を通してisShowMessageの値を状態管理しなければなりません。Reactの場合も、同じように状態管理しなければならないと思っていたことによる勘違いでした。

まとめ

Reactの基本的なHooksであるuseStateuseEffect、及びReactで再レンダリングが起こる条件について解説しました。

Vue.jsを使っていて、これまで再レンダリングのタイミングについてあまり意識することはありませんでした。なぜかと思い調べてみると、Vueではフレームワーク側がコンポーネントの依存関係を自動的に追跡し、状態が変化したときに、どのコンポーネントを再描画する必要があるかを正確に認識しているようです。

ReactのuseEffectの処理を、Vueでは裏側で良しなにやってくれていたということですね。

追記のところでも書きましたが、このレンダリングの挙動の違いが、VueとReactの差になっているのかなと思いました。Vueの感覚でReactを書くと、私のように無駄に状態管理する値を増やしてしまいがちなので気を付けましょう(笑)