2020.05.10

React入門チュートリアル (6) 副作用


この連載記事は、これから React を学びたい JavaScript 開発者のための入門コンテンツです。対象とする React のバージョンは執筆時点で最新の v16.13 です。

1️⃣ Reactとは何か
2️⃣ JSX
3️⃣ 属性と状態
4️⃣ フォームとイベントハンドリング
5️⃣ ToDoアプリを作ってみよう
6️⃣ 副作用
7️⃣ カスタムフック
8️⃣ Reactプロジェクトを始める方法

⚠ 理解を助けることを意図としているため、網羅的、リファレンス的な解説はしていません。ドキュメントを併読すると、さらに理解が深まると思います。

本章では、状態と並んで重要な React の概念である「副作用」について学びます。

副作用とは

副作用(side effect)」とは、UI の構築、つまり「JSX を返却する処理、および、そのための状態やイベントハンドラの定義」以外の処理です。

副作用の実例としては、API との非同期通信や、主に React 非対応のライブラリを使用するために React の管理外で DOM を更新する処理などが挙げられます。

しかし、その性質を突き詰めると以下の2点に集約されると思います。

  • DOM の存在を前提とした処理(更新、参照など)
  • 状態(state)の更新

これらは、後述する useEffect というフック関数を通して実行します。

function Compoent() {
  // ❌ ここはダメ
  someSideEffects(); // 副作用

  React.useEffect(() => {
    // ✅ ここなら OK
    someSideEffects(); // 副作用
  }, [])
}

なぜこれらの「副作用」は、useEffect の中で実行される必要があるのでしょうか?

その理由は、「タイミング合わせ」です。結論から言うと:

  • DOM を参照したければ、React によって DOM が作られるまで待たなければいけない。
  • 状態の更新により再レンダリングが発生するので、無限ループを避けるため、適切な場所で行う必要がある。

たとえば DOM の操作について考えてみましょう。

基本的には DOM 操作は React がやってくれるのですが、React に対応していない JavaScript ライブラリを組み合わせて使いたい場合は、直接 DOM を参照する必要が出てきます。

DOM を参照するには、DOM が存在していなければいけません。当たり前ですよね。しかし React が絡むと話がややこしくなります。なぜなら、コンポーネントから JSX が返却されてから、実際にいつ DOM が生成され、既存の DOM に反映されるのか、分からないからです。

function Component() {
  // ❌ このタイミングでは当然まだ DOM は構築されていない
  new ExternalLibrary('.target-class');

  return (
    // JSX...
  );
}

useEffect に渡したコールバック関数は、必ず、レンダリングが完了した後に実行されます。そのため、そのコールバック内であれば安全に DOM を参照できます。

function Component() {
  React.useEffect(() => {
    new ExternalLibrary('.target-class');
  }, []);

  return (
    // JSX...
  );
}

次に、状態の更新について考えます。

属性と状態」の章で説明したように、状態を更新すると(状態更新関数を実行すると)、再レンダリングが発生します。つまり、コンポーネントの関数が再実行されます。

そのため、適切な場所で状態を更新しないと、無限ループになってしまいます。以下の例では、setVal が実行され、Component が再実行され、また setVal が実行され…という無限ループに陥ります。実際には、React が無限ループを察知してエラーを発します。

function Component() {
  const [val, setVal] = React.useState(0);

  // ❌ 無限ループを引き起こす
  setVal(123);
}

useEffect に渡したコールバック関数はレンダリングの後に呼ばれますが、さらに第二引数によって、呼ばれるタイミングを制御できます。以下の例では、初回レンダリングのときのみコールバックが実行され、2回目以降の再レンダリング時には実行されません。

function Component() {
  const [val, setVal] = React.useState(0);

  React.useEffect(() => {
    setVal(123);
  }, []);
}

このように、React では、コンポーネントの DOM への変換や状態の更新といった React 独自の仕組みとタイミングを合わせながら実行すべき処理を「副作用」と呼び、その実行タイミングの制御のために useEffect フック関数を提供しています。

そして、結局は冒頭で述べたように、UI 構築以外の処理は副作用に該当します。

では、ここから useEffect フックについてより詳しく見ていきます。

useEffect

useEffect は、副作用の実行のために React が提供するフック関数です。

useEffect は以下のシグネチャを持ちます。

説明
第一引数 関数 副作用の処理内容
何も返さないか、クリーンアップ関数を返す
第二引数 配列 依存データ(省略可能)
第一引数の実行タイミングを制御する
React.useEffect(() => {
  document.title = 'Hello world';
}, []);

すでに上述しましたが、useEffect の第一引数は、そのコンポーネントがレンダリングされた直後に実行されます。

データと副作用の同期

第二引数の依存データについて説明します。

useEffect のコールバックはコンポーネントがレンダリングされるたびに実行されますが、第二引数によって、実行される条件を制御することができます。

実行条件には以下の3パターンがあると覚えてください。

  1. レンダリング後に必ず実行する。
  2. 初回のレンダリング後だけ実行する。
  3. 指定したデータに変更があった場合のみ実行する。

それぞれ見ていきましょう。

まず、第二引数を省略すると、コールバックはレンダリング後に毎回実行されます。

React.useEffect(() => {
  // ...
});

次に、第二引数に空の配列を指定すると、初回のレンダリング時にのみ、コールバックが実行されます。2回目以降の再レンダリング後は実行されません。

React.useEffect(() => {
  document.title = 'Hello world';
}, []);

たとえば、ページ表示時に API から画面に必要なデータを取得して、レスポンスを元に状態を更新する…というような処理は、再レンダリング後に実行する必要はありません。むしろ無駄な通信が発生させないために、初回だけに限るほうがよいです。

最後に、第二引数の配列に変数を入れると、レンダリング後に、その変数に更新があったときのみ、コールバックが実行されます。この変数は、ほとんどの場合、props か state です。

React.useEffect(() => {
  document.title = `Welcome, ${userName}!`;
}, [userName]);

配列なので、複数のデータを指定できます。

React.useEffect(() => {
  document.title = `Welcome, ${firstName} ${lastName}!`;
}, [firstName, lastName]);

先ほどの API の例で言うと、その API を呼ぶために何か state を参照する必要がある場合は、その state を指定すれば OK です。

基本的には、コールバック内で参照している props、state、およびそれらを元に算出された値は、すべて配列に入れておく、と覚えてしまえばいいと思います。

逆に、たとえばコンポーネント関数の外のスコープで定義されている変数および関数など、内容に更新がかかる可能性のないデータを指定する必要はありません。

クリーンアップ関数

useEffect のコールバックから返されたクリーンアップ関数は、コンポーネントがアンマウントされるたびに呼ばれます。

前提として、コンポーネントが既存の DOM に適用、追加されることを「マウント(mount)」、逆に DOM から削除されることを「アンマウント(unmount)」と言います。

コンポーネントがアンマウントされるのは、たとえば条件分岐でコンポーネントを出し分けしていて、ある条件で画面から消えたときなどです。

以下のサンプルは、実際に手元で動かしてみてほしいのですが、num が奇数のときに Child が画面から消えて、そのタイミングでコンソールに "bye" が出力されているはずです。

function App() {
  const [num, setNum] = React.useState(0);

  const handleClick = () => setNum(num + 1);

  return (
    <>
      <button onClick={handleClick}>{num}</button>
      {num % 2 === 0 && <Children />}
    </>
  );
}

function Children() {
  React.useEffect(() => {
    console.log('hello');

    // 👇 クリーンアップ関数を返している
    // この関数は、Child がアンマウントされるときに実行される
    return () => console.log('bye');
  }, []);

  return <p>I am a child.</p>;
}

const root = document.getElementById('root');
ReactDOM.render(<App/>, root);

ではクリーンアップが必要なのはどのような場合でしょうか?

実際、クリーンアップ関数を返さなくてはいけないケースはそう多くはありません。イベントを登録している場合や、非同期通信を subscribe している場合などが挙げられるでしょう。

分かりやすい例として、イベントを直接登録する場合を考えてみましょう。

以下のようにクリーンアップをしないと、コンポーネントがマウントされるたびにイベントが重複して登録されてしまいます。

React.useEffect(() => {
  function handler() {
    // ...
  }

  window.addEventListener('resize', handler);

  return () => {
    window.removeEventListener('resize', handler);
  };
}, []);

クリーンアップが必要ない場合は、コールバックからは何も返す必要がありません。undefined 以外の何かを返すとコンソールに警告が出力されます。

ここまでで useEffect フック関数の使用方法を学んだので、具体的なユースケースをいくつか紹介します。

非同期処理

まずは非同期処理です。

受け取った props を元に、UI の構築に必要なデータを API から取得するサンプルです。

function User({ userId }) {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(json => setUser(json));
  }, [userId]);

  return user ? <h1>Hello, {user.name}</h1> : <p>Loading...</p>;
}

then の 代わりに async / await 構文を用いる場合は、注意が必要です。

普通に考えると以下のように書いてしまいますが、これは NG です。なぜなら、async 関数の仕様で、返却値が指定されない場合は、暗黙的に Promise オブジェクトが返されるからです。

// ❌
React.useEffect(async () => {
  const response = await fetch(`/api/users/${userId}`).then(res => res.json());

  setUser(response);
}, [userId]);

コールバックからは、関数を返すか、もしくは何も返さない(undefined)必要があるので、以下のように、コールバック内で async 関数を定義して、それを実行するように記述します。

// ✅
React.useEffect(() => {
  async function fetchUser() {
    const response = await fetch(`/api/users/${userId}`)
      .then(res => res.json());

    setUser(response);
  }
  fetchUser();
}, [userId]);

または、即時実行関数を用いても OK です。

// ✅
React.useEffect(() => {
  (async function() {
    const response = await fetch(`/api/users/${userId}`)
      .then(res => res.json());

    setUser(response);
  })();
}, [userId]);

DOM 操作

次に DOM 操作の例です。

人気のあるスライダーライブラリ、Swiper を組み込むサンプルです。

function ImageSlider({ id, images }) {
  const [swiper, setSwiper] = React.useState(null);

  React.useEffect(() => {
    const instance = new Swiper(`#${id}`, {
      // Options...
    });
    setSwiper(instance);
  }, [id]);

  React.useEffect(() => {
    swiper.update();
  }, [images]);

  return (
    <div id={id} className="swiper-container">
      <div className="swiper-wrapper">
        {images.map(image => (
          <div key={image.id} className="swiper-slide">
            <img src={image.url} alt={image.title} />
          </div>
        ))}
      </div>
    </div>
  );
}
  1. 一つ目の useEffect で Swiper を初期化します。
  2. 二つ目の useEffect では、props の images に更新があるたびに、スライダーを構築し直しています。

このように、useEffect は、「この変数が更新されたらこの処理を実行したい」というパターンにも利用できます。

練習問題

問題 1

ダミーユーザーデータを返してくれる API サービス、RANDOM USER GENERATOR を使って、ユーザーデータを表示するテーブルを作成しましょう。

API の URL は以下を用います。

API
https://randomuser.me/api/?results=5&nat=us&inc=gender,name,email

レスポンスは以下の形式の JSON です。

{
  "results": [
    {
      "gender": "male",
      "name": {
        "title": "Mr",
        "first": "Rene",
        "last": "King"
      },
      "email": "rene.king@example.com"
    },
    // 4 other items...
  ],
  "info": {
    "seed": "7a1d53b3e2ad2008",
    "results": 5,
    "page": 1,
    "version": "1.3"
  }
}

この JSON データから、以下のテーブルを生成します。

Name Gender Email
Mr. Rene King male rene.king@example.com
... ... ...
... ... ...
... ... ...
... ... ...

🙌 解答例

こちらの CodePen を参照してください。

問題 2

ツールチップライブラリ Tippy を組み込みましょう。

準備として、以下のコードを読み込む必要があります。

<script src="https://unpkg.com/@popperjs/core@2/dist/umd/popper.min.js"></script>
<script src="https://unpkg.com/tippy.js@6/dist/tippy-bundle.umd.js"></script>

CodePen をお使いの場合は、"JS" の左の歯車マークから設定ウィンドウを開いて、"Add External Scripts/Pens" に上の二つのスクリプトの URL を追加します。

マウスを乗せたらツールチップが表示されるボタンコンポーネント TooltipButton を実装してください。

/* TooltipButton コンポーネントを実装する */

function App() {
  return (
    <TooltipButton id="myButton" content="Hello world!">
      Hover me
    </TooltipButton>
  );
}

const root = document.getElementById('root');
ReactDOM.render(<App />, root);

🙌 解答例

こちらの CodePen を参照してください。

関連記事

連載

  1. Reactとは何か
  2. JSX
  3. 属性と状態
  4. フォームとイベントハンドリング
  5. ToDoアプリを作ってみよう
  6. 副作用
  7. カスタムフック
  8. Reactプロジェクトを始める方法