1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useEffect 完全ガイド — 「使わない」判断から始める

結論

useEffect は「React の外側にあるシステムと同期する」ためだけの道具。 それ以外の用途で書いた useEffect は、ほぼ全部バグの温床になる。

外側のシステムとは:

  • ブラウザ API(addEventListenerIntersectionObservermatchMedia
  • タイマー(setIntervalsetTimeout
  • ネットワーク(fetch、WebSocket)
  • サードパーティライブラリのインスタンス(地図、エディタ、チャート)
  • Electron の IPC、Capacitor のネイティブプラグイン

逆に言えば、画面に表示する値を計算するために useEffect を書いていたら、それは間違い。


1. まず「要らない useEffect」を消す

React 公式が "You Might Not Need an Effect" というドキュメントをわざわざ用意しているくらい、ここが最大のハマりどころ。典型パターンを潰していく。

❌ パターン①: 派生 state を useEffect で作る

// ダメ: レンダリングが2回走る + 一瞬 fullName が空になる
function Profile({ firstName, lastName }) {
  const [fullName, setFullName] = useState('');

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

  return <h1>{fullName}</h1>;
}
// ✅ 正解: レンダリング中に計算するだけ
function Profile({ firstName, lastName }) {
  const fullName = `${firstName} ${lastName}`;
  return <h1>{fullName}</h1>;
}

既存の props / state から計算できる値は、state にしてはいけない。 計算が本当に重い場合だけ useMemo を挟む。

// 数千件のフィルタリングなど、実測して重かった場合のみ
const visibleTodos = useMemo(
  () => todos.filter((t) => !t.done),
  [todos]
);

❌ パターン②: イベントハンドラでやるべき処理

// ダメ: 「なぜこれが動くのか」が追えないコードになる
useEffect(() => {
  if (isSubmitted) {
    showToast('保存しました');
    router.push('/list');
  }
}, [isSubmitted]);
// ✅ 正解: 「ボタンを押したから起きること」はハンドラに書く
const handleSubmit = async () => {
  await save(data);
  showToast('保存しました');
  router.push('/list');
};

判断基準はシンプル:

「ユーザーが何かしたから起きること」→ イベントハンドラ
「この画面が表示されているから起きること」→ useEffect

❌ パターン③: props が変わったら state をリセット

// ダメ: 一瞬だけ前のユーザーのコメントが表示される
useEffect(() => {
  setComment('');
}, [userId]);
// ✅ 正解: key を渡してコンポーネントごと作り直させる
<ProfileForm key={userId} userId={userId} />

key が変わると React は state を丸ごと捨てて新しいインスタンスを作る。useEffect より速く、確実。

❌ パターン④: 親への通知

// ダメ: 子 → 親の更新が1フレーム遅れる
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
  onToggle(isOpen);
}, [isOpen]);
// ✅ 正解: 同じイベントの中で両方やる
const handleClick = () => {
  const next = !isOpen;
  setIsOpen(next);
  onToggle(next);
};

2. 正しい useEffect: 外部システムとの同期

ここからが本来の用途。

イベントリスナーの購読

function useWindowSize() {
  const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });

  useEffect(() => {
    const handleResize = () => {
      setSize({ w: window.innerWidth, h: window.innerHeight });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize); // 必須
  }, []);

  return size;
}

Electron の IPC

useEffect(() => {
  const handleFileOpened = (_event, filePath) => {
    dispatch({ type: 'fileOpened', filePath });
  };

  const unsubscribe = window.electronAPI.onFileOpened(handleFileOpened);
  return () => unsubscribe(); // preload 側で off() を返す設計にしておく
}, []);

preload で ipcRenderer.on() を露出するときは、必ず解除用の関数を返す形にする。返さないとリスナーが積み上がって、1回のファイルオープンで N 回ハンドラが走る。

Capacitor のネイティブイベント

useEffect(() => {
  let listener;

  (async () => {
    listener = await App.addListener('appStateChange', ({ isActive }) => {
      if (isActive) refetch();
    });
  })();

  return () => {
    listener?.remove();
  };
}, [refetch]);

Capacitor の addListener は Promise を返すので、cleanup 時点でまだ解決していない可能性がある。上のように変数で受けるか、ignore フラグを併用する。

サードパーティライブラリのライフサイクル

useEffect(() => {
  const editor = new CodeMirror(ref.current, { value: initialValue });
  return () => editor.destroy();
}, []); // マウント時に生成、アンマウント時に破棄

3. クリーンアップ関数を「必ず」書く

useEffect は、cleanup を書かないと壊れる前提で設計されている。 開発モードの StrictMode では、React が意図的に「マウント → アンマウント → 再マウント」を実行する。これは cleanup 漏れを検出するための機能。

[dev] effect実行 → cleanup実行 → effect実行

つまり cleanup が正しく書かれていれば、2回実行されても何も問題が起きない。もし2回実行で挙動がおかしくなるなら、それは本番でも起きうるバグ(ページ遷移の戻る操作など)。

非同期処理の race condition

これが一番怖い。

// ❌ ダメ: userId が素早く 1 → 2 と変わると、1 のレスポンスが後着して上書きすることがある
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);
// ✅ 正解: ignore フラグで古いレスポンスを捨てる
useEffect(() => {
  let ignore = false;

  fetchUser(userId).then((data) => {
    if (!ignore) setUser(data);
  });

  return () => {
    ignore = true;
  };
}, [userId]);

AbortController を使えば通信そのものもキャンセルできる:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });

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

実務では、この手のデータ取得は自前で書かず TanStack Query / SWR に任せるのが正解。キャッシュ、重複排除、リトライ、race condition 対策が全部入っている。useEffect で fetch を書くのは、ライブラリを入れられない事情があるときだけ。


4. 依存配列を正しく理解する

依存配列は「実行タイミングを制御するスイッチ」ではなく、「effect の中で使っている外部の値の一覧」。ここを取り違えると詰む。

useEffect(() => {
  // ...
}, [a, b]); // a か b が変わったら cleanup → 再実行
useEffect(() => {}, []);  // マウント時のみ
useEffect(() => {});      // 毎レンダリング(ほぼ使わない)

比較は Object.is(浅い比較)

// ❌ options は毎レンダリング新しいオブジェクト → 毎回 effect が再実行される
function Chat({ roomId }) {
  const options = { serverUrl: 'https://...', roomId };

  useEffect(() => {
    const conn = createConnection(options);
    conn.connect();
    return () => conn.disconnect();
  }, [options]); // 実質「毎回」
}
// ✅ オブジェクトの生成を effect の中に入れる
function Chat({ roomId }) {
  useEffect(() => {
    const options = { serverUrl: 'https://...', roomId };
    const conn = createConnection(options);
    conn.connect();
    return () => conn.disconnect();
  }, [roomId]); // プリミティブだけを依存に置く
}

依存配列にはできる限りプリミティブ(string / number / boolean)だけを置く。 オブジェクトや関数を置きたくなったら、まず effect の中に移動できないか考える。

依存を「消す」のではなく「不要にする」

lint の警告を握りつぶすのは最悪手。

// ❌ 絶対にやらない
useEffect(() => {
  doSomething(count);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

setState の更新関数形式を使えば、依存を減らせることが多い:

// ❌ count が依存に必要 → 毎秒 interval が張り直される
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, [count]);

// ✅ 更新関数形式なら count を読まなくて済む
useEffect(() => {
  const id = setInterval(() => setCount((c) => c + 1), 1000);
  return () => clearInterval(id);
}, []);

5. 「最新の値は読みたいが、再実行はしたくない」問題

よくあるのがこれ。ログ送信やコールバック呼び出しで、最新の props は読みたいが、それが変わるたびに接続を張り直したくはない。

// ❌ theme が変わるたびに再接続されてしまう
useEffect(() => {
  const conn = createConnection(roomId);
  conn.on('connected', () => showNotification('接続', theme));
  conn.connect();
  return () => conn.disconnect();
}, [roomId, theme]);

解法A: ref に逃がす(どのバージョンでも使える)

const themeRef = useRef(theme);
useEffect(() => {
  themeRef.current = theme;
});

useEffect(() => {
  const conn = createConnection(roomId);
  conn.on('connected', () => showNotification('接続', themeRef.current));
  conn.connect();
  return () => conn.disconnect();
}, [roomId]); // theme は依存に不要

解法B: useEffectEvent(新しめの React で利用可)

import { useEffectEvent } from 'react';

const onConnected = useEffectEvent(() => {
  showNotification('接続', theme); // 常に最新の theme を読む
});

useEffect(() => {
  const conn = createConnection(roomId);
  conn.on('connected', onConnected);
  conn.connect();
  return () => conn.disconnect();
}, [roomId]); // onConnected は依存に入れない

導入前に使っている React のバージョンで安定版として提供されているか確認したほうがいい。使えないなら解法A で十分。


6. useLayoutEffect との使い分け

実行タイミング 用途
useEffect ブラウザの描画(非同期) 通常はこちら
useLayoutEffect DOM 更新後・描画(同期) レイアウト計測して即座に位置を直す場合
// ツールチップの高さを測って、はみ出すなら上下反転する
useLayoutEffect(() => {
  const { height } = ref.current.getBoundingClientRect();
  setTooltipHeight(height); // 描画前に確定するのでチラつかない
}, []);

useLayoutEffect はメインスレッドをブロックするので、計測 → 即反映が必要な場合以外は使わない


7. 実務チェックリスト

useEffect を書いたら、以下を自問する。

  • この処理、レンダリング中の計算で済まないか?(派生 state になっていないか)
  • この処理、イベントハンドラに書くべきではないか?
  • cleanup 関数を返しているか?
  • StrictMode で2回実行されても正しく動くか?
  • 非同期処理なら ignore フラグか AbortController があるか?
  • 依存配列にオブジェクト・関数が入っていないか?
  • eslint の警告を無視していないか?

まとめ

  • useEffect は「React 外部との同期」専用。表示用の値の計算には使わない
  • 消せる useEffect が、コードベースには想像以上に多い
  • cleanup は義務。StrictMode の二重実行は cleanup 漏れ検出のための機能
  • 依存配列は制御スイッチではなく、使っている値の宣言
  • データ取得は原則ライブラリに任せる

まず「この useEffect は消せないか」を疑う。 それが useEffect と付き合う一番の近道。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?