useEffectは、依存配列の値が変わったときに任意の処理を実行するための汎用フックではありません。Reactの外部にある仕組みと、コンポーネントの状態を同期するための手段です。

この境界を意識しないまま使うと、派生値を別のstateへコピーしたり、イベント処理を遠回りさせたり、不要な再レンダリングを増やしたりします。依存配列の警告を直し続けても、そもそもEffectが不要なら問題は解決しません。

この記事では、よくある五つの反パターンと、本当にuseEffectが必要な場面を整理します。

まず確認したい基本例

一覧をキーワードで絞り込む処理を、次のように書くことがあります。

const [items, setItems] = useState<Item[]>([]);
const [filteredItems, setFilteredItems] = useState<Item[]>([]);
const [keyword, setKeyword] = useState("");

useEffect(() => {
  setFilteredItems(
    items.filter((item) => item.name.includes(keyword)),
  );
}, [items, keyword]);

このコードでは、itemsまたはkeywordが変わるたびに、古いfilteredItemsを使ったレンダリングが一度行われます。その後にEffectが実行され、setFilteredItemsによってもう一度レンダリングされます。

filteredItemsは既存のstateから計算できるため、独立したstateとして持つ必要はありません。

const [items, setItems] = useState<Item[]>([]);
const [keyword, setKeyword] = useState("");

const filteredItems = items.filter((item) =>
  item.name.includes(keyword),
);

計算量が大きく、実際に性能上の問題が確認できた場合はuseMemoを検討できます。ただし、単純な計算まで機械的にメモ化する必要はありません。

const filteredItems = useMemo(
  () => items.filter((item) => item.name.includes(keyword)),
  [items, keyword],
);

React公式ドキュメントでも、propsやstateから計算できる値はレンダリング中に求め、不要なEffectとstateを増やさない考え方が説明されています。

反パターン1:派生値をEffectでstateへコピーする

合計金額のような値も、同じ問題を起こしやすい例です。

// 避けたい例:cartから計算できるtotalを別のstateへコピーする
const [cart, setCart] = useState<CartItem[]>([]);
const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(
    cart.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    ),
  );
}, [cart]);

この構成には、次の問題があります。

  • cartの更新後に、合計金額を更新するためのレンダリングが追加で発生する
  • carttotalが一時的に食い違う状態を作る
  • stateと依存配列という管理対象が増える
  • 合計金額の計算場所が離れ、データの関係を追いにくくなる

直接計算すれば、データの関係がコード上でも明確になります。

const [cart, setCart] = useState<CartItem[]>([]);

const total = cart.reduce(
  (sum, item) => sum + item.price * item.quantity,
  0,
);

判断基準は単純です。

既存のpropsやstateから毎回計算できる値なら、まずstateへ保存しない方法を検討します。

反パターン2:イベント処理をEffectへ迂回させる

フォーム送信後に保存、計測、通知を行う処理を、stateの変更を介して実行する例があります。

// 避けたい例:submittedを処理開始の信号として使う
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (!submitted) {
    return;
  }

  sendAnalytics("form_submit");
  showToast("保存しました");
  setSubmitted(false);
}, [submitted]);

const handleSubmit = async () => {
  await saveForm(data);
  setSubmitted(true);
};

submittedには画面上の状態としての意味がなく、別の処理を起動する信号としてだけ使われています。この構成では、setSubmitted(true)を読んでも、その先で何が起きるのか分かりません。

ユーザー操作によって発生する処理は、イベントハンドラへまとめた方が因果関係を追いやすくなります。

const handleSubmit = async () => {
  try {
    await saveForm(data);

    // 保存成功という同じイベントに属する処理をまとめる
    sendAnalytics("form_submit");
    showToast("保存しました");
  } catch {
    showToast("保存に失敗しました");
  }
};

Effectはレンダリング結果に応じて実行されます。一方、イベントハンドラは「どの操作が起点だったか」を把握できます。

ボタンのクリックやフォーム送信など、特定の操作が原因で実行する処理は、原則としてそのイベントハンドラへ置きます。

反パターン3:データ取得を毎回手書きのEffectで実装する

useEffectでデータを取得すること自体が誤りというわけではありません。React公式ドキュメントでも、Effectによるデータ取得は可能だと説明されています。

ただし、コンポーネント内へ直接書く場合は、少なくとも次の問題を自分で扱う必要があります。

  • リクエスト中、成功、失敗の状態
  • コンポーネントの更新やアンマウント後に古い結果を反映しないための処理
  • 同じデータに対する重複リクエスト
  • キャッシュと再取得の条件
  • リトライ
  • SSRやルーターとの連携
  • リクエストウォーターフォール
// 小規模な例でも、古いリクエストの中断処理が必要になる
useEffect(() => {
  const controller = new AbortController();

  async function loadUser() {
    setStatus("pending");

    try {
      const user = await fetchUser(userId, {
        signal: controller.signal,
      });

      setUser(user);
      setStatus("success");
    } catch (error) {
      if (controller.signal.aborted) {
        return;
      }

      setError(error);
      setStatus("error");
    }
  }

  void loadUser();

  return () => {
    controller.abort();
  };
}, [userId]);

画面が増え、同じデータを複数箇所で使うなら、フレームワークのloaderやサーバーコンポーネント、TanStack Query、SWRなどを検討した方が、取得処理の責務をまとめやすくなります。

TanStack Query v5では、たとえば次のように記述できます。

const {
  data: user,
  isPending,
  error,
} = useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetchUser(userId),
});

queryKeyへ取得条件を含めることで、条件ごとのキャッシュと再取得を管理できます。ただし、ライブラリを導入すれば自動的に正しい設計になるわけではありません。staleTime、再取得、リトライなどの既定値を理解し、製品要件に合わせて設定する必要があります。

判断基準は「データ取得にEffectを使っているか」ではなく、キャッシュ、競合、SSR、再取得を含むデータ取得のライフサイクルを、どこが責任を持って管理するかです。

反パターン4:「一度だけ実行したい処理」をEffectへ置く

次のコードは、空の依存配列によって一度だけ実行されるように見えます。

useEffect(() => {
  initSdk({ appId: "example" });
  registerGlobalHandler();
}, []);

しかし、Strict Modeが有効な開発環境では、ReactがEffectのセットアップとクリーンアップを追加で一度実行します。これは本番環境で無条件に二度実行されるという意味ではなく、クリーンアップ不足や再マウントに弱い実装を見つけるための確認です。

useRefで二回目の実行を隠すだけでは、必要なクリーンアップが欠けたままになる場合があります。また、コンポーネントが実際にアンマウントされて再度マウントされれば、refも作り直されます。

アプリ起動時に一度だけ行う処理で、コンポーネントの表示状態と関係がないなら、エントリーポイントや専用モジュールへ置く方が責務を明確にできます。

// main.tsx
initializeApplication();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

初期化関数は、複数回呼ばれても壊れないようにしておくと安全です。

let initialized = false;

export function initializeApplication() {
  if (initialized) {
    return;
  }

  initialized = true;
  initSdk({ appId: "example" });
  registerGlobalHandler();
}

一方、コンポーネントが表示されている間だけ必要な接続や購読なら、Effectでセットアップし、対応するクリーンアップを返します。

useEffect(() => {
  const unsubscribe = subscribeToGlobalEvents(handleEvent);

  return () => {
    unsubscribe();
  };
}, [handleEvent]);

反パターン5:複数のstateをEffectで同期する

姓名から表示名を作る処理を、三つのstateへ分ける必要はありません。

// 避けたい例:fullNameを別のstateとして同期する
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

表示名はレンダリング中に計算できます。

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");

const fullName = `${firstName} ${lastName}`.trim();

「Aが変わったらBも変更する」というEffectを書き始めたら、次の可能性を確認します。

  • BはAから計算できる派生値ではないか
  • AとBを一つのstateへまとめるべきではないか
  • stateを共通の親へ引き上げるべきではないか
  • コンポーネントの対象そのものが変わるなら、keyでstateをリセットできないか
  • 変更の起点がユーザー操作なら、同じイベント内で更新できないか

別々のstateに同じ情報を重複して持つほど、同期漏れが起きやすくなります。

useEffectが適している場面

Effectが必要になる代表的な場面は、Reactの外部システムと同期するときです。

DOMやブラウザAPIを購読する

useEffect(() => {
  const element = ref.current;

  if (!element) {
    return;
  }

  const observer = new ResizeObserver(([entry]) => {
    setWidth(entry.contentRect.width);
  });

  observer.observe(element);

  return () => {
    observer.disconnect();
  };
}, []);

WebSocketへ接続する

useEffect(() => {
  const socket = new WebSocket(url);

  const handleMessage = (event: MessageEvent<string>) => {
    const message = JSON.parse(event.data) as Message;

    setMessages((current) => [...current, message]);
  };

  socket.addEventListener("message", handleMessage);

  return () => {
    socket.removeEventListener("message", handleMessage);
    socket.close();
  };
}, [url]);

React外のUIライブラリを組み込む

useEffect(() => {
  const canvas = canvasRef.current;

  if (!canvas) {
    return;
  }

  const chart = new Chart(canvas, chartConfig);

  return () => {
    chart.destroy();
  };
}, [chartConfig]);

この例では、chartConfigの参照が毎回変わるとChartが作り直されます。設定をコンポーネント外へ移す、useMemoで安定させる、Effect内で必要な値から組み立てるなど、依存関係も合わせて設計します。

ページタイトルを同期する

useEffect(() => {
  const previousTitle = document.title;
  document.title = `${user.name} | Profile`;

  return () => {
    document.title = previousTitle;
  };
}, [user.name]);

共通しているのは、DOM、ブラウザAPI、ネットワーク接続、第三者ライブラリなど、Reactだけでは管理できない対象があることです。

速見表

実現したいことEffect基本方針
propsやstateから表示値を計算する不要レンダリング中に計算する
重い計算結果を再利用する通常は不要必要性を計測してuseMemoを検討する
ボタン操作をきっかけに処理する不要イベントハンドラへ置く
フォーム送信後に計測や通知を行う不要送信処理と同じイベント内で行う
APIからデータを取得する場合によるフレームワークやデータ取得ライブラリを優先的に検討する
props変更時に画面全体のstateをリセットする多くは不要対象の同一性が変わるならkeyを検討する
二つのstateを同じ値に保つ多くは不要派生値、stateの統合、stateの引き上げを検討する
アプリ起動時にSDKを初期化する多くは不要エントリーポイントや冪等な初期化モジュールへ置く
windowやDOMイベントを購読する必要購読と解除を対にする
WebSocketへ接続する必要接続と切断を対にする
React外のUIライブラリを同期する必要インスタンス作成と破棄を対にする
document.titleを表示内容へ合わせる必要変更と復元をEffectで扱う

Effectを追加する前の確認項目

新しいEffectを書く前に、次の順番で確認すると判断しやすくなります。

  1. Reactの外部システムと同期しているか
  2. propsやstateからレンダリング中に計算できないか
  3. 特定のユーザー操作が起点なら、イベントハンドラへ置けないか
  4. 同じ情報を複数のstateへ重複して保存していないか
  5. データ取得のキャッシュや競合を、コンポーネントごとに再実装していないか
  6. セットアップと対になるクリーンアップがあるか
  7. 依存配列へ入るオブジェクトや関数が毎回作り直されていないか

useEffectは避けるべき機能ではありません。必要な場面では、Reactと外部システムをつなぐ重要な手段です。

問題は、値の計算やイベント処理までEffectへ寄せ、レンダリングと副作用の境界を曖昧にすることです。Effectを追加する前に「何と同期しているのか」を説明できれば、不要なstate、余分なレンダリング、追いにくい依存関係を減らせます。