useEffectの基本的なアンチパターン
はじめに
私はこれまで何となく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秒後に実行されるタイマーを設定します。
- コンポーネントがアンマウントされるときに、クリーンアップ関数が呼ばれて設定されたタイマーをクリアします。
このようにクリーンアップ関数を使用することで、不要なタイマーの実行を防ぎ、メモリリークや予期しない挙動を回避することができます。
👇 クリーンアップ関数についてはこちらの記事がとてもわかりやすかったです。
👇 詳しくは公式ドキュメントをご覧ください。
不要なuseEffectを使用してしまった実装
次に、私が不要なuseEffectを使用してしまったユースケースについて簡単に説明したいと思います。
-
実装した機能
- リスト形式で表示しているデータをセレクトボックスで選択された値でフィルタリングする機能。
- セレクトボックスで選択した値のidをURLのクエリパラメーターに設定して、設定されたクエリパラメーターを読み取り、そのidをリクエストで送信することで、選択されたデータを取得します。
↓ 画面イメージ
私はこのユースケースで、子コンポーネントのセレクトボックスに選択した「カテゴリー名」を表示させるために、useEffectを使用してクエリパラメータの値が変更されたら、useEffectの処理を発火させる実装を行なってしまいました。
この実装では、クエリパラメータの値が更新されたタイミングでReactコンポーネントが再レンダリングされるため、useEffectを使用しないでもセレクトボックスに新たに選択した「カテゴリー名」を設定することができます。
そのため、今回の実装でuseEffectを使用してしまうと、不要なレンダリングが発生してしまいます。
[今回の実装での再レンダリングの流れ]
1.ユーザーがセレクトボックスでカテゴリーを選択
↓
2. URLのクエリパラメータの更新
↓
3. Reactコンポーネントの再レンダリング
↓
4. 現在のカテゴリー名の再計算
この「再レンダリングの流れ」により、クエリパラメータが更新されたタイミングで再レンダリングが発生するので、useEffectは不要な実装になっていました。
👇 Reactコンポーネントの再レンダリングについては以下の記事がすごくわかりやすかったです。
基本的なアンチパターン
では、次にuseEffectの基本的なアンチパターンについてコード例を用いて解説していきます。
これらのアンチパターンは、公式ドキュメントに記載されている内容を元にしています。
1. レンダーのためのデータ変換
- 「レンダーのためのデータ変換」とは
- Reactコンポーネントがレンダリングされる際に、表示するデータを適切な形式に変換すること。
- コンポーネントが受け取ったデータを表示する前にフィルタリングしたりした上で表示すること。
[よくある実装]
- リストのフィルタリング
- データの並び替え
- 計算結果の表示
リストデータのフィリタリングをトップレベルで行う例
データの絞り込みを行う検索フォームコンポーネントを親として、データをフィルタリングしてリスト表示するコンポーネントを子とした例で実装してみます。
❌ BAD
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を使用して親の入力値が変更されたらフィルタリング処理を発火させる実装を行なっています。
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 [子コンポーネント]
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の状態が変更されるタイミングでリクエストを送信するタイミングを制御しています。
- ボタンクリックで余計な再レンダリングが発生してしまいます。
- コードの可読性が悪くなってしまいます。
"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 [商品購入ボタンコンポーネント]
"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を使用するのではなく、コンポーネントのレンダリングの仕組みなどもしっかり理解した上で使用していく必要があると思いました。
参考情報
Discussion