🙅‍♂️

useEffectの基本的なアンチパターン

2024/07/02に公開

はじめに

私はこれまで何となくuseEffectを使いまくることは良くないという認識でいましたが、具体的にどのようなユースケースでuseEffectを使用すると良くないのかまで理解できていなかったため、今回改めて調べてみようと思いました。

useEffectとは

最初にuseEffectの基本について説明します。

useEffectはReactのフックの一つで、副作用を管理するために使用されます。このフックを使用することで、コンポーネントのレンダリング後に特定の関数を実行するタイミングを制御することができます。

基本的な実装方法

import { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // 副作用のロジックをここに書きます

    return () => {
      // クリーンアップのロジックをここに書きます
    };
  }, []); // 依存配列
}

依存配列について

  • 依存配列に特定の値を指定することで、その値が変更されるたびにuseEffect内の副作用を実行することができます。
  • 空の配列[]の場合は、初回レンダリング時のみ副作用が実行されます。

クリーンアップ関数について

  • useEffect内で設定した副作用をクリーンアップするための関数になります。これにより、メモリークを防ぎコンポーネントのパフォーマンスの向上させることができます。
  • クリーンアップの実行タイミング
    • コンポーネントのマウント時に初回のエフェクト(副作用)が発火されます。クリーンアップ関数は以下のタイミングで実行されます。
      • コンポーネントのアンマウント時。
      • コンポーネントの再レンダリング時。

[クリーンアップ関数の実装例(タイマーのクリア)]

useEffect(() => {
  const timer = setTimeout(() => {
    console.log('1秒後に実行される');
  }, 1000);
  
  return () => clearTimeout(timer);
}, []);

このuseEffectの処理は以下のような機能を持っています。

  1. コンポーネントがマウントされたときに、1秒後に実行されるタイマーを設定します。
  2. コンポーネントがアンマウントされるときに、クリーンアップ関数が呼ばれて設定されたタイマーをクリアします。

このようにクリーンアップ関数を使用することで、不要なタイマーの実行を防ぎ、メモリリークや予期しない挙動を回避することができます。

👇 クリーンアップ関数についてはこちらの記事がとてもわかりやすかったです。

https://zenn.dev/yumemi_inc/articles/react-effect-simply-explained

👇 詳しくは公式ドキュメントをご覧ください。
https://ja.react.dev/reference/react/useEffect

不要なuseEffectを使用してしまった実装

次に、私が不要なuseEffectを使用してしまったユースケースについて簡単に説明したいと思います。

  • 実装した機能
    • リスト形式で表示しているデータをセレクトボックスで選択された値でフィルタリングする機能。
    • セレクトボックスで選択した値のidをURLのクエリパラメーターに設定して、設定されたクエリパラメーターを読み取り、そのidをリクエストで送信することで、選択されたデータを取得します。

↓ 画面イメージ

私はこのユースケースで、子コンポーネントのセレクトボックスに選択した「カテゴリー名」を表示させるために、useEffectを使用してクエリパラメータの値が変更されたら、useEffectの処理を発火させる実装を行なってしまいました。

この実装では、クエリパラメータの値が更新されたタイミングでReactコンポーネントが再レンダリングされるため、useEffectを使用しないでもセレクトボックスに新たに選択した「カテゴリー名」を設定することができます。

そのため、今回の実装でuseEffectを使用してしまうと、不要なレンダリングが発生してしまいます。

[今回の実装での再レンダリングの流れ]

1.ユーザーがセレクトボックスでカテゴリーを選択 
↓
2. URLのクエリパラメータの更新 
↓
3. Reactコンポーネントの再レンダリング
↓
4. 現在のカテゴリー名の再計算

この「再レンダリングの流れ」により、クエリパラメータが更新されたタイミングで再レンダリングが発生するので、useEffectは不要な実装になっていました。

👇 Reactコンポーネントの再レンダリングについては以下の記事がすごくわかりやすかったです。

https://zenn.dev/b1essk/articles/react-re-rendering

https://qiita.com/yokoto/items/ee3ed0b3ca905b9016d3

基本的なアンチパターン

では、次にuseEffectの基本的なアンチパターンについてコード例を用いて解説していきます。
これらのアンチパターンは、公式ドキュメントに記載されている内容を元にしています。

https://ja.react.dev/learn/you-might-not-need-an-effect

1. レンダーのためのデータ変換

  • 「レンダーのためのデータ変換」とは
    • Reactコンポーネントがレンダリングされる際に、表示するデータを適切な形式に変換すること。
    • コンポーネントが受け取ったデータを表示する前にフィルタリングしたりした上で表示すること。

[よくある実装]

  • リストのフィルタリング
  • データの並び替え
  • 計算結果の表示

リストデータのフィリタリングをトップレベルで行う例

データの絞り込みを行う検索フォームコンポーネントを親として、データをフィルタリングしてリスト表示するコンポーネントを子とした例で実装してみます。

❌ BAD

search_form.jsx [親コンポーネント]
検索フィールドに入力された値を子コンポーネントに渡しています。

search_form.jsx
"use client";
import FilteredListNotGood from "@/app/useEffect/_components/filtered_list_not_good";
import { useState } from "react";

export default function SearchForm() {
  // inputに入力された値を保持する
  const [filter, setFilter] = useState("");
  // テストデータ
  const testListData = ["apple", "banana", "orange", "pear"];

  const handleSearchChange = (e) => {
    setFilter(e.target.value);
  };
  return (
    <>
      <input
        type="text"
        placeholder="果物をフィルタリング..."
        value={filter}
        onChange={handleSearchChange}
        className="border border-gray-300 rounded-md p-2"
      />
      <FilteredListNotGood list={testListData} filter={filter} />
    </>
  );
}

filtered_list_not_good.jsx [子コンポーネント]
親から渡ってきたpropsを受け取り、useEffectを使用して親の入力値が変更されたらフィルタリング処理を発火させる実装を行なっています。

filtered_list_not_good.jsx
import React, { useState, useEffect } from "react";

export default function FilteredListNotGood({ list, filter }) {
  const [filteredItems, setFilteredItems] = useState([]);

  useEffect(() => {
    const filtered = list.filter((item) => item.includes(filter));
    setFilteredItems(filtered);
  }, [list, filter]);

  return (
    <ul>
      {filteredItems.map((item, index) => (
        <li key={index}>{item}</li>
      ))}
    </ul>
  );
}

ブラウザでの表示

この時の親と子コンポーネントのレンダリング回数を「React Developer Tools」を使用して確認してみます。

↑こちらは親コンポーネントのレンダリング回数を計測しています。検索フォームに3文字入力してので、3回再レンダリングが発生しています。

↑それに対して、子コンポーネントは倍の6回も再レンダリングが発生してしまっています。

なぜ、子コンポーネントが親コンポーネントの倍もレンダリングが発生してしまっているかというと、親コンポーネントでinputフィールドに入力がされるたびにstateが更新されて再レンダリングが発生しているため、子コンポーネントでuseEffectを使用しないでもデータを更新することができているためになります。

⭕️ GOOD

親コンポーネントは先ほどのコードと同じものを使用して、子コンポーネントのみuseEffectを使用していない形に変更します。

filtered_list_good.jsx [子コンポーネント]

filtered_list_good.jsx
import React from "react";

export default function FilteredListGood({ list, filter }) {
  const filteredItems = list.filter((item) => item.includes(filter));

  return (
    <ul>
      {filteredItems.map((item, index) => (
        <li key={index}>{item}</li>
      ))}
    </ul>
  );
}

↓この変更により、子コンポーネントのレンダリング回数が3回になりました。

このように、親コンポーネントで再レンダリングが発生している際に、子コンポーネントでpropsの値を発火基準にしてuseEffectを使用してしまうと、不要な再レンダリングが発生してしまうケースがあります。

2. ユーザーイベントの処理にはエフェクトは必要ない

次に、ユーザーのクリックイベントを起点にリクエストを送信する際にuseEffectが不要である内容について解説したいと思います。

購入するボタンをクリックしてPOSTリクエストを送信する例

❌ BAD

buy_button_not_good.jsx [商品購入ボタンコンポーネント]

  • useEffectを使用して、useStateの状態が変更されるタイミングでリクエストを送信するタイミングを制御しています。
  • ボタンクリックで余計な再レンダリングが発生してしまいます。
  • コードの可読性が悪くなってしまいます。
buy_button_not_good.jsx
"use client";
import React, { useState, useEffect } from "react";

export default function BuyButtonNotGood() {
  const [buy, setBuy] = useState(false);

  useEffect(() => {
    if (buy) {
      // 非同期で /api/buy に POST リクエストを送信
      fetch("/api/buy", { method: "POST" })
        .then((response) => response.json())
        .then((data) => {
          // 通知を表示するなどの処理
          alert("購入が完了しました");
          setBuy(false); // 状態をリセット
        });
    }
  }, [buy]);

  const handleBuyClick = () => {
    setBuy(true);
  };

  return (
    <button
      onClick={handleBuyClick}
      className="border border-1 border-black p-1 rounded"
    >
      購入する
    </button>
  );
}

コンポーネントのレンダリングが1回発生してしまっています。

⭕️ GOOD

  • イベントハンドラーでリクエストを送信しています。

buy_button_good.jsx [商品購入ボタンコンポーネント]

buy_button_good.jsx
"use client";
import React from "react";

export default function BuyButtonGood() {
  const handleBuyClick = () => {
    // 非同期で /api/buy に POST リクエストを送信
    fetch("/api/buy", { method: "POST" })
      .then((response) => response.json())
      .then((data) => {
        // 通知を表示するなどの処理
        alert("購入が完了しました");
      });
  };

  return <button onClick={handleBuyClick} className="border border-1 border-black p-1">購入する</button>;
}

イベントハンドラーのhandleBuyClick関数内でリクエストを送信する処理を記述することで、useEffectを使用していた時にコンポーネントが再レンダリングされていましたが、今回は再レンダリングが発生しなくなりました。

このように、ユーザーのクリックイベントを起点にリクエストを送信する際の処理にuseEffectを使用してしまうと、不要な再レンダリングが発生してしまいます。

3. 依存配列の設定ミス

依存配列は、エフェクトが実行されるタイミングを制御するもので、空の依存配列([])の場合は、コンポーネントのマウント時に一度だけ実行されます。

カウントアップボタンの実装

カウントアップボタンをクリックしたら、1ずつカウントアップしていく機能を例として解説していきます。

❌ BAD

以下のコードでは、依存配列を空にしているためカウントアップボタンをクリックしてもuseEffect内の処理は発火せず、コンポーネントの初回マウント時にしか実行されません。

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

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

  useEffect(() => {
    console.log(`カウントが変更されました: ${count}`);
    // 依存配列が空のため、countの変更は反映されない
  }, []); 

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>カウントアップボタン</button>
    </div>
  );
};

⭕️ GOOD

countを依存配列に追加することで、countの値が変更されるたびにuseEffectが再実行されます。

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

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

  useEffect(() => {
    console.log(`カウントが変更されました: ${count}`);
    // countが変更されるたびにエフェクトが再実行される
  }, [count]); 

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>カウントアップボタン</button>
    </div>
  );
};

4. クリーンアップ忘れ

カウントアップ処理

1秒ずつにカウントアップされていく処理を例として解説していきます。

❌ BAD

クリーンアップしないとコンポーネントがアンマウントされた後もカウントアップしつづけてしまう。

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

export default function Timer (){
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((prevCount) => prevCount + 1);
    }, 1000);
    // クリーンアップ関数を忘れている
  }, []);

  return <div>{count}</div>;
};

⭕️ GOOD

クリーンアップを追加することで、アンマウント時にカウントアップがクリアされます。

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

export default function Timer (){
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((prevCount) => prevCount + 1);
    }, 1000);
    
    return () => clearInterval(interval); // クリーンアップ関数を追加
  }, []);

  return <div>{count}</div>;
};

最後に

useEffectの基本的なアンチパターンについて調べてみましたが、Reactのレンダリングの仕組みをしっかりと理解して実装しないと、useEffectの副作用に重い処理が行われていた場合にアプリケーションのパフォーマンスが悪くなってしまうなと思いました。そのため、今後はやみくもにuseEffectを使用するのではなく、コンポーネントのレンダリングの仕組みなどもしっかり理解した上で使用していく必要があると思いました。

参考情報

https://ja.react.dev/reference/react/useEffect
https://ja.react.dev/learn/keeping-components-pure
https://ja.react.dev/learn/you-might-not-need-an-effect
https://zenn.dev/yumemi_inc/articles/react-effect-simply-explained
https://zenn.dev/uhyo/articles/useeffect-taught-by-extremist
https://qiita.com/Mitsuw0/items/801f783ca74b062c1ed8
https://qiita.com/seira/items/e62890f11e91f6b9653f

GitHubで編集を提案

Discussion