normcore.dev

useEffectの正しい使いどころと、使わなくていい典型パターン

TL;DR(先に結論)

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

この記事では、

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

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

[!NOTE]
React 18 の Strict Mode について
React 18 以降、開発環境で Strict Mode が有効だと useEffect が2回実行される仕様になりました。
これは「副作用を正しくクリーンアップしているか」をテストするための意図的な動作です。
2回実行されて壊れる useEffect は、そもそもクリーンアップが足りていないサイン。
本番環境では1回だけ実行されるので、ユーザーに影響はありません。


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
    • localStorage.getItem / setItem
  • ネットワーク・サーバー
    • 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. パターン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 もいらない
  • 合計・フィルタ・ソート結果・フォーマット済みテキストなど、全部このカテゴリに入りがち

[!TIP]
useMemo / useCallback との使い分け
「その場で計算」が基本ですが、計算コストが高い場合useMemo でキャッシュを検討してください。

// 計算が重いときだけ useMemo を使う
const sortedItems = useMemo(() => {
  return items.slice().sort((a, b) => a.name.localeCompare(b.name));
}, [items]);

ただし 最初から useMemo を使う必要はない
「まずは普通に書く → 遅かったら useMemo」の順番で OK です。
useCallback も同様で、子コンポーネントへの props 最適化時に検討。


4. パターン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:本当に「一時編集用」の場合(モーダルなど)

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

  // 「開いたときだけ」初期値をコピーするなど、明確なトリガーに絞る
  useEffect(() => {
    if (open) {
      setDraft(initialValue);
    }
  }, [open, initialValue]);

  if (!open) return null;

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

※場合によっては key={initialValue} を使ってコンポーネントごとリセットする方がシンプルなこともある。

4-6. 覚えること

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

5. パターン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 回レンダリング

  • ルールが 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. パターン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. パターン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変更時にデータフェッチ
    • ただし、React Query / SWR 等のライブラリに任せる構成も増えている
  2. サブスクリプションやリスナーの管理

    • WebSocket 接続の開始・終了
    • window.addEventListener / removeEventListener
    • setInterval / clearInterval などのタイマー
    useEffect(() => {
      const handleResize = () => {
        setWidth(window.innerWidth);
      };
    
      window.addEventListener('resize', handleResize);
    
      // ✅ クリーンアップ関数で後片付け
      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }, []);
    

    [!IMPORTANT]
    クリーンアップを忘れると…

    • リスナーが残り続けてメモリリーク
    • コンポーネントが unmount しても処理が走り続ける
    • Strict Mode で2回実行されるとリスナーが重複登録される

    return で後片付けを書く習慣をつけるとよさそう。

  3. DOM を直接触る必要がある特殊ケース

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

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

    ⚠️ useLayoutEffect はブラウザの描画をブロックするため、重い処理には向きません。

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


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 の出番」
    と判断できるようになると、コードの質が一段上がる。
← Back to home