normcore.dev

useEffectは「最後の手段」。Reactの「外」と「中」を理解して、不要なコードを消す技術

TL;DR(先に結論)

  • useEffect は “最後の手段(escape hatch)”
    React の「外」と同期するときだけ使うもの。
  • 「React の 中だけ で完結すること」に使っていたら、ほぼ確実に設計ミス。
  • 不要な useEffect が多いと
    • 再レンダリングが増えてパフォーマンス悪化
    • バグの温床
    • コードが読みにくくなる

この記事では、

  1. 「React の 」のイメージ
  2. やりがちな「不要な useEffect」5パターンと、その直し方
  3. じゃあ どんなときなら useEffect を使ってよいのか
  4. 最後にレビュー用チェックリスト

までを、初心者でもイメージできるレベルで整理します。


1. 「React の中」と「外」をちゃんと分ける

1-1. React の「中」(inside)

React が面倒を見てくれる世界です。

  • コンポーネント関数の中
  • props
  • useState, useReducer の state
  • それらから計算される値
    const fullName = firstName + lastName みたいなやつ)
  • JSX (return <div>...</div>)
  • イベントハンドラの中で
    • state を更新する (setState)
    • 親からもらったコールバックを呼ぶ

特徴:

  • すべて「ただの JavaScript の計算」で完結していて、
  • 結果は「React が DOM を再描画してくれる」ことで UI に反映される

ここで完結する話に、useEffect は不要


1-2. React の「外」(outside)

React が管理していない世界
ここに触るときに useEffect が登場する可能性が出てきます。

具体例:

  • DOM を直接触る
    • document.getElementById(...)
    • ref.current.focus()
    • ref.current.scrollIntoView()
    • ref.current.classList.add(...) / remove(...)
  • ブラウザのグローバル API
    • window.addEventListener('resize', ...)
    • window.removeEventListener(...)
    • setInterval / setTimeout
  • 永続化 API
    • localStorage.getItem / setItem
    • sessionStorage
    • IndexedDB
  • ネットワーク・サーバー
    • fetch('/api/...')
    • WebSocket, EventSource
  • 外部ライブラリ
    • Chart.js / Map ライブラリ / カスタムウィジェットなど
  • 時間に依存する処理
    • 現在時刻を定期的に更新する、カウントダウンなど

特徴:

  • React はこの世界の後始末を自動ではやってくれない
  • 自分で「開始」と「終了(クリーンアップ)」を意識する必要がある

ここを扱うための“逃げ道”が useEffect


1-3. 判断の合言葉

今書こうとしている処理は
「React の中だけで完結する話か? 外に触っている話か?」

  • 中だけ → useEffect 使うな(ほぼ確実に不要)
  • 外に触る → useEffect 候補(ただし本当に必要かはまだ検討)

このモデルを頭に置いたまま、「不要な useEffect」パターンを見ていきます。


2. 「不要な useEffect」5つのアンチパターン

一覧

# アンチパターン名 ありがちな目的 正しい方針
1 派生状態同期 useEffect 別 state から計算できる値を保持 その場で計算する
2 props コピー useEffect props をローカル state にコピー 親で管理 or key でリセット
3 バリデーション useEffect isValid フラグ更新 関数で判定して毎回計算
4 イベント処理 useEffect submitted フラグを見て処理 イベントハンドラ内で完結
5 見た目切り替え useEffect classList / style 直操作 JSX で className / style を条件分岐

ここから 1 つずつ。


3. NGパターン1:派生状態を useEffect で同期している

3-1. イメージ

  • firstName, lastName から fullName を作りたい
  • なぜか fullNameuseState して、 useEffect で更新

3-2. NGコード

function UserForm() {
  const [firstName, setFirstName] = useState('太郎');
  const [lastName, setLastName] = useState('山田');

  // ❌ やりがち:派生値を state に持つ
  const [fullName, setFullName] = useState('');

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

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

3-3. 何がダメか

  • 同じ情報を二重に持っている

    • fullName は firstName と lastName から常に計算可能
  • 無駄なレンダリング

    • 初回:fullName は空 → レンダー
    • useEffect → setFullName → 再レンダー
  • 「何か特別な理由があるのか?」と読み手を混乱させる

    • state + useEffect があると「外部との同期かな?」と思わせてしまう

3-4. OKコード

function UserForm() {
  const [firstName, setFirstName] = useState('太郎');
  const [lastName, setLastName] = useState('山田');

  // ✅ 派生値はその場で計算
  const fullName = `${lastName} ${firstName}`;

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

3-5. 覚えること

  • 他の state / props から100%計算できる値」は state にしない
    → useEffect もいらない
  • 配列の .filter().sort()、日時のフォーマット変換などは、100%このカテゴリです

4. NGパターン2:props を state にコピーして useEffect で同期している

4-1. イメージ

  • 親が initialValue を渡してくる
  • 子で value state を作り、useEffectinitialValue を常に追いかける

4-2. NGコード

function MyInput({ initialValue }: { initialValue: string }) {
  const [value, setValue] = useState(initialValue);

  // ❌ props の変化を追いかけて state 更新
  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);

  return <input value={value} onChange={e => setValue(e.target.value)} />;
}

4-3. 何がダメか

  • 真実の値(ソース)が分かりづらい

    • initialValue が正なのか、value が正なのか、迷う
  • ここでも再レンダリングの二度手間

    • props 変化 → レンダー → useEffect → setValue → 再レンダー
  • 責務が崩れている

    • 本来「値の管理」は親の役割
    • 子が “props の変化を監視して同期” みたいな変な仕事を持つ

4-4. OKコードA:親で管理する

function MyInput({
  value,
  onChange,
}: {
  value: string;
  onChange: (v: string) => void;
}) {
  // ✅ 親から来た値をそのまま使う
  return <input value={value} onChange={e => onChange(e.target.value)} />;
}

function Parent() {
  const [name, setName] = useState('山田太郎');
  return <MyInput value={name} onChange={setName} />;
}

4-5. OKコードB:key を使ってコンポーネントをリセットする(推奨)

「モーダルを開くたびに初期値に戻したい」場合、key を使うのがシンプルで安全です。

// ✅ 親コンポーネントで key を指定
function Parent() {
  const [open, setOpen] = useState(false);
  const [editTarget, setEditTarget] = useState({ id: 1, name: '山田' });

  return (
    <EditModal
      key={editTarget.id}  // id が変わるとコンポーネントが再マウントされる
      open={open}
      initialValue={editTarget.name}
      onSave={(v) => {
        setEditTarget({ ...editTarget, name: v });
        setOpen(false);
      }}
    />
  );
}

function EditModal({
  open,
  initialValue,
  onSave,
}: {
  open: boolean;
  initialValue: string;
  onSave: (v: string) => void;
}) {
  // ✅ useState の初期値がそのまま使われる(useEffect 不要)
  const [draft, setDraft] = useState(initialValue);

  if (!open) return null;

  return (
    <div>
      <input value={draft} onChange={e => setDraft(e.target.value)} />
      <button onClick={() => onSave(draft)}>保存</button>
    </div>
  );
}

4-6. OKコードC:どうしても useEffect が必要な場合

key でのリセットが使えない場合(例:アニメーション中に状態を保持したいなど)、「閉→開」の瞬間だけを検知する必要があります。

function EditModal({
  open,
  initialValue,
  onSave,
}: {
  open: boolean;
  initialValue: string;
  onSave: (v: string) => void;
}) {
  const [draft, setDraft] = useState(initialValue);
  const prevOpen = useRef(open);

  useEffect(() => {
    // ✅ 「閉じていた → 開いた」瞬間だけリセット
    if (open && !prevOpen.current) {
      setDraft(initialValue);
    }
    prevOpen.current = open;
  }, [open, initialValue]);

  if (!open) return null;

  return (
    <div>
      <input value={draft} onChange={e => setDraft(e.target.value)} />
      <button onClick={() => onSave(draft)}>保存</button>
    </div>
  );
}

4-7. 覚えること

  • props をそのまま state にコピーしていないか?」を必ず疑う
  • 理想は「真実の状態は親に一つだけ」
  • 子が state を持つのは「一時編集」など、理由がはっきりしている場合だけ
  • リセットが必要な場合は key を使うのが第一選択

5. NGパターン3:バリデーション結果を useEffect+state で持っている

5-1. イメージ

  • emailpassword から isValid を出したい
  • isValid も state にして useEffect で更新している

5-2. NGコード

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  // ❌ isValid を state + useEffect で管理
  const [isValid, setIsValid] = useState(false);

  useEffect(() => {
    const ok = email.includes('@') && password.length >= 8;
    setIsValid(ok);
  }, [email, password]);

  return (
    <form>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button disabled={!isValid}>ログイン</button>
    </form>
  );
}

5-3. 何がダメか

  • isValid も計算で求まるだけ

  • 無駄な 2 回レンダリング(パターン1と同様)

  • ルールが useEffect の中に埋もれる

    • 「@必須」「8文字以上」みたいな仕様がパッと見で分からない

5-4. OKコード

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  // ✅ その場で計算する
  const isValid = email.includes('@') && password.length >= 8;

  return (
    <form>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      <button disabled={!isValid}>ログイン</button>
    </form>
  );
}

ルールを関数に切り出すと、さらに分かりやすい:

function validateLogin(email: string, password: string): boolean {
  // ※ 実際のメールアドレス検証はもっと厳密に(正規表現やライブラリ推奨)
  return email.includes('@') && password.length >= 8;
}

const isValid = validateLogin(email, password);

5-5. 覚えること

  • これはただの判定ロジックだよね?」と思ったら
    → 関数化して毎回計算。state + useEffect は不要
  • useEffect は「計算する場所」じゃなくて、「外部と同期を取る場所」

6. NGパターン4:ユーザーイベントの処理を「フラグ+useEffect」でやっている

6-1. イメージ

  • 「送信ボタン押されたら API を呼びたい」
  • submitted フラグを立てて、useEffect でそれを見て API を呼ぶ

6-2. NGコード

function ContactForm() {
  const [message, setMessage] = useState('');
  const [submitted, setSubmitted] = useState(false);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitted(true);
  };

  // ❌ イベントの本体処理を useEffect に追い出している
  useEffect(() => {
    if (!submitted) return;

    fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify({ message }),
    }).then(() => {
      alert('送信しました');
    });
  }, [submitted, message]);

  return (
    <form onSubmit={handleSubmit}>
      <textarea value={message} onChange={e => setMessage(e.target.value)} />
      <button type="submit">送信</button>
    </form>
  );
}

6-3. 何がダメか

  • イベント → 処理 の対応関係がコードに出てこない

    • 実際にやりたいのは「submit → API」なのに、
    • コード上は「submit → submitted を true → useEffect が API」を見に行く
      というムダなワンクッションがある
  • 条件が増えると地獄

    • ローディング、エラー、リトライ…と増やすと
      state フラグだらけ+useEffect 内 if だらけになる

6-4. OKコード:イベントハンドラ内で完結させる

function ContactForm() {
  const [message, setMessage] = useState('');
  const [isSending, setIsSending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSending(true);
    setError(null);

    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        body: JSON.stringify({ message }),
      });
      if (!res.ok) {
        throw new Error('送信に失敗しました');
      }
      alert('送信しました');
    } catch (err) {
      // ✅ エラーも state で管理して UI に反映
      setError(err instanceof Error ? err.message : '不明なエラー');
    } finally {
      setIsSending(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <textarea value={message} onChange={e => setMessage(e.target.value)} />
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <button type="submit" disabled={isSending}>
        {isSending ? '送信中…' : '送信'}
      </button>
    </form>
  );
}

6-5. 覚えること

  • 「ユーザー操作に対する処理」はイベントハンドラで完結させる
  • useEffect を使うのは、「描画が反映された後の処理」が本当に必要なときだけ

7. NGパターン5:見た目の切り替えを useEffect+DOM操作でやっている

7-1. イメージ

  • active フラグに応じてパネルの見た目を変えたい
  • ref.current.classList.add/remove を useEffect で叩く

7-2. NGコード

function Panel({ active }: { active: boolean }) {
  const ref = useRef<HTMLDivElement | null>(null);

  // ❌ DOM を直接いじって class を変える
  useEffect(() => {
    if (!ref.current) return;
    if (active) {
      ref.current.classList.add('active');
    } else {
      ref.current.classList.remove('active');
    }
  }, [active]);

  return <div ref={ref}>パネル</div>;
}

7-3. 何がダメか

  • React の「状態 → UI」っていう一番の強みを捨てている

    • className は普通に JSX で条件分岐すればいいだけ
  • DOMと状態のズレリスク

    • StrictMode の二重実行や ref のタイミングずれなどで想定外の動きになる可能性

7-4. OKコード

function Panel({ active }: { active: boolean }) {
  return (
    <div className={active ? 'panel active' : 'panel'}>
      パネル
    </div>
  );
}

style でも同じ:

function Panel({ active }: { active: boolean }) {
  return (
    <div style={{ opacity: active ? 1 : 0.5 }}>
      パネル
    </div>
  );
}

7-5. 覚えること

  • この DOM 操作、className / style の条件分岐で書けない?

    • 書けるなら、100% そっちが正解

8. 結局、どんなときなら useEffect を使っていいのか?

ここまで「使うな」ばかりだったので、OK パターンだけ整理します。

useEffect を使うべき典型ケース

  1. 外部 API からのデータ取得

    • コンポーネントのマウント時やID変更時にデータフェッチ
  2. サブスクリプションやリスナーの管理

    • WebSocket 接続の開始・終了
    • window.addEventListener / removeEventListener
    • setInterval / clearInterval などのタイマー
    useEffect(() => {
      const handleResize = () => {
        setWidth(window.innerWidth);
      };
    
      window.addEventListener('resize', handleResize);
    
      // ✅ クリーンアップ関数で後片付け
      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }, []);
    
  3. DOM を直接触る必要がある特殊ケース

    • 特定要素へのフォーカス
    • スクロール位置の調整
    • サードパーティ描画ライブラリの初期化・破棄
  4. レイアウト計算が必要なケース(useLayoutEffect)

    • 要素の幅・高さを測って位置を調整する場合
    • useEffect だと ブラウザのペイント後 に実行されるため、ちらつきが起きることがある
    • useLayoutEffectDOMへのコミット後、ブラウザがペイントする前 に同期的に実行されるので、
      ユーザーに一瞬だけ古い状態を見せたくない場合に使う
    useLayoutEffect(() => {
      // DOM を測定してから state を更新 → ペイント前に反映されるのでちらつかない
      const height = ref.current?.offsetHeight ?? 0;
      setContainerHeight(height);
    }, []);
    

この4カテゴリに入っていない useEffect は、まず疑っていい


8.5. 判断フローチャート

useEffect を使うべきかどうか迷ったら、以下の質問に答えてください。

まず最初の質問:React の「外」を触っている?

答え 次のステップ
NO(中だけで完結) → 下の「内側チェック」へ
YES(外を触る) → 下の「外側チェック」へ

「内側」の場合:本当に useEffect が必要?

質問 YES なら NO なら
他の state/props から計算で求まる値 不要:その場で計算 次の質問へ
ユーザー操作への応答? 不要:イベントハンドラで処理 次の質問へ
**見た目(className/style)**の切り替え? 不要:JSX で条件分岐 次の質問へ
すべて NO 🤔 本当に必要か再検討

「外側」の場合:何を触る?

触るもの 結論
データ取得(fetch / API) ⚠️ useEffect 可(ただしライブラリ推奨
リスナー / タイマー ✅ useEffect(クリーンアップ必須
DOM 操作(レイアウト計測あり) useLayoutEffect
DOM 操作(レイアウト計測なし) ✅ useEffect
外部ライブラリ(Chart.js, Map など) ✅ useEffect(初期化・破棄を管理
外部ストアの購読(Redux, Zustand 等) useSyncExternalStore を検討

8.6. 依存配列について

依存配列(第二引数の [...])の扱いは useEffect の誤用で最も多いポイントです。

基本ルール

useEffect(() => {
  // userId を使った処理
  fetchUser(userId);
}, [userId]);  // ← userId を使っているので依存配列に含める

useEffect 内で参照しているすべての値を依存配列に含めるのが大原則です。

ESLint ルールを有効にする

ESLint の react-hooks/exhaustive-deps ルールを error にしておくと、依存配列の漏れを自動検出できます。

// .eslintrc.json
{
  "rules": {
    "react-hooks/exhaustive-deps": "error"
  }
}

意図的に依存を省く場合

どうしても特定の依存を省きたい場合は、理由をコメントで明示してください:

useEffect(() => {
  // マウント時に一度だけ実行したい
  analytics.pageView(pageName);
  // eslint-disable-next-line react-hooks/exhaustive-deps -- 初回マウント時のみ記録したいため
}, []);

9. レビュー用チェックリスト

PRレビューやセルフレビューで、useEffect を見たらこれを回す:

  1. この処理は React の外を触っているか?

    • DOM 直操作 / イベントリスナー / タイマー / fetch / WebSocket…
    • 触っていないなら → useEffect ほぼ不要
  2. この state は、他の state/props から計算できないか?

    • YES → state+useEffect で同期するな。毎回計算せよ
  3. ユーザーイベントの処理を「フラグ+useEffect」でやってないか?

    • YES → イベントハンドラに処理を戻せるはず
  4. 見た目(className/style)の切り替えを DOM 操作でやってないか?

    • YES → JSX の条件分岐に移せるはず

10. さいごに

  • useEffect は「悪」じゃない。
    でも、「Reactの中だけで完結する話なのに使っている」時点で設計が負けている。
  • 「中」と「外」を意識して線を引き、
    「これは中の話だから useEffect 使うな」
    「これは外の話だから useEffect の出番」
    と判断できるようになると、コードの質が一段上がる。

明日からできること

  1. useEffect を見たら「これは中?外?」と1秒考える

    • 中の話なら、ほぼ確実に書き直せる
  2. ESLint ルールを強化する

    {
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "error"
    }
    
  3. データフェッチを useEffect から専用ライブラリに移行する

    • TanStack Query / SWR / RTK Query など
  4. 既存コードで「派生状態の同期」パターンを探して潰す

    • useEffect + setState のセットを検索すると見つかりやすい
← Back to home