2
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をボタンハンドラーに移動したら、コードが劇的に読みやすくなった話

Last updated at Posted at 2025-11-26

この記事でいいたいことを、一言でまとめると

  • useEffectは必須ではないケースが多い
  • useEffectは「状態や外部変化に反応する」ためのもの
  • 状態が変わるのは主に2つ
    • ユーザー操作由来
      → handleClickの中で直接処理すればuseEffect不要
    • 外部イベント由来(WebSocket, タイマー, DOMなど)
      → この場合はuseEffectで監視・反応させる必要がある

はじめに

Reactを書いていると、「とりあえず useEffect に入れておけばいいか」と思うこと、ありませんか?

しかし、React公式には、わざわざ、そのuseEffect使わなくていいかも と説明する You Might Not Need an Effect というページがあるくらい、アンチパターンとしてつかわれることが多いみたいです。

私も先日、コードレビューで「useEffectじゃなくてボタン押した後の処理でできないかな?」という指摘をもらいました。

最初は「同じことでは?」と思いましたが、実際にリファクタリングしてみると、コードの意図が明確になり、デバッグもしやすくなるという大きなメリットがありました。

この記事では、実際のリファクタリング事例を通じて、useEffectの適切な使い所について考えてみます。

Before: useEffectで「自動的に」処理していたコード

元のコード

ツリー構造のゴール選択画面で、画面遷移時に「他人のゴールの子要素」を自動的に取得する処理がありました。

// GoalSelectionContent.tsx

// 他人のゴールの子要素を自動的に取得
useEffect(() => {
  if (!currentGoal || !currentMemberId) return;

  const needsFetch = (goal: typeof currentGoal) =>
    goal.hasChildren === true &&
    (!goal.children || goal.children.length === 0);

  // 現在のゴールが他人のもので、子要素未取得なら取得
  if (
    isOwnedByOther(currentGoal, currentMemberId) &&
    needsFetch(currentGoal) &&
    shouldAddToQueue(currentGoal)
  ) {
    fetchAndExpandChildren([currentGoal.id]);
  }

  // 子ゴールについても同様にチェック
  if (currentGoal.children) {
    const othersGoalsToExpand = currentGoal.children.filter(
      (child) =>
        isOwnedByOther(child, currentMemberId) &&
        needsFetch(child) &&
        shouldAddToQueue(child),
    );
    if (othersGoalsToExpand.length > 0) {
      fetchAndExpandChildren(othersGoalsToExpand.map((g) => g.id));
    }
  }
}, [currentGoal, currentMemberId, fetchAndExpandChildren]);

何が問題だったのか

一見すると動作しているコードですが、いくつかの問題がありました:

1. 暗黙的なトリガー

currentGoal が変わるたびに自動実行されますが、「いつ」「なぜ」実行されるのかがコードを読んだだけでは分かりにくい。

2. デバッグの難しさ

「次へ」ボタンを押したのか、「戻る」ボタンを押したのか、初期ロードなのか——どのタイミングでフェッチが発生したのか追跡しづらい。

3. 副作用の連鎖が見えない

ボタン押下 → state更新 → useEffect発火 → API呼び出し

この流れが暗黙的で、コードを追いかけるのが大変。

After: ボタンハンドラーで「明示的に」処理するコード

リファクタリング後のコード

まず、ナビゲーションフック(useGoalNavigation)を修正して、遷移後の新しいcurrentGoalを返すようにしました:

// useGoalNavigation.ts

type GoBackResult = {
  newCurrentGoal: ObjectiveWithChildren | null;
};

type GoNextResult = {
  isComplete: boolean;
  goalsToFetch: string[];
  newCurrentGoal: ObjectiveWithChildren | null;  // 追加
};

const goBack = useCallback((): GoBackResult => {
  if (processingStack.length > 0) {
    const newStack = processingStack.slice(0, -1);
    setProcessingStack(newStack);

    // 新しいcurrentGoalを計算して返す
    if (newStack.length > 0) {
      const topStack = newStack[newStack.length - 1];
      return { newCurrentGoal: findGoalById(goals, topStack.parentId) };
    } else {
      const currentRootId = topLevelQueue[currentTopLevelIndex];
      return {
        newCurrentGoal: currentRootId
          ? findGoalById(goals, currentRootId)
          : null,
      };
    }
  }
  // ... 省略
}, [processingStack, currentTopLevelIndex, goals, topLevelQueue, router]);

次に、useEffectを削除し、ヘルパー関数とボタンハンドラーに移動しました:

// GoalSelectionContent.tsx

// ヘルパー関数: 他人のゴールの子要素を取得
const fetchOthersChildren = useCallback(
  async (goal: typeof currentGoal) => {
    if (!goal || !currentMemberId) return;

    const needsFetch = (g: typeof goal) =>
      g !== null &&
      g.hasChildren === true &&
      (!g.children || g.children.length === 0);

    const goalsToFetch: string[] = [];

    if (
      isOwnedByOther(goal, currentMemberId) &&
      needsFetch(goal) &&
      shouldAddToQueue(goal)
    ) {
      goalsToFetch.push(goal.id);
    }

    if (goal.children) {
      const othersGoalsToExpand = goal.children.filter(
        (child) =>
          isOwnedByOther(child, currentMemberId) &&
          needsFetch(child) &&
          shouldAddToQueue(child),
      );
      goalsToFetch.push(...othersGoalsToExpand.map((g) => g.id));
    }

    if (goalsToFetch.length > 0) {
      await fetchAndExpandChildren(goalsToFetch);
    }
  },
  [currentMemberId, fetchAndExpandChildren],
);

// 「戻る」ボタンハンドラ
const handleBack = useCallback(async () => {
  const { newCurrentGoal } = goBack();
  await fetchOthersChildren(newCurrentGoal);  // 明示的に呼び出し
}, [goBack, fetchOthersChildren]);

// 「次へ」ボタンハンドラ
const handleNext = useCallback(async () => {
  markCurrentGoalsAsVisited();
  const { goalsToFetch, newCurrentGoal } = goNext();

  if (goalsToFetch.length > 0) {
    await fetchAndExpandChildren(goalsToFetch);
  }

  await fetchOthersChildren(newCurrentGoal);  // 明示的に呼び出し
}, [markCurrentGoalsAsVisited, goNext, fetchAndExpandChildren, fetchOthersChildren]);

比較: 何が変わったのか

観点 Before (useEffect) After (ボタンハンドラー)
トリガー currentGoal の変更(暗黙的) ボタン押下(明示的)
実行タイミング 分かりにくい 一目瞭然
データフロー state変更 → useEffect発火 ハンドラー内で直接実行
デバッグ console.logを入れても追いにくい ハンドラーにブレークポイントを置けばOK
テスト useEffectのモックが必要 関数を直接テスト可能

useEffectを使うべき場面・使わないべき場面

useEffectを使うべき場面

// 1. 外部システムとの同期
useEffect(() => {
  const ws = new WebSocket('wss://...');
  return () => ws.close();
}, []);

// 2. DOMの直接操作
useEffect(() => {
  inputRef.current?.focus();
}, [isOpen]);

// 3. 購読の設定(イベントリスナーなど)
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

useEffectを使わないべき場面

// ❌ ユーザーアクションに応じた処理
useEffect(() => {
  if (shouldFetch) {
    fetchData();
  }
}, [shouldFetch]);

// ✅ イベントハンドラーで直接実行
const handleClick = async () => {
  await fetchData();
};
// ❌ 派生状態の計算
useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);

// ✅ 直接計算(useMemoも不要なケースが多い)
const fullName = firstName + ' ' + lastName;

判断のフローチャート

その処理はユーザーアクションの結果ですか?
  └─ Yes → イベントハンドラーで実行
  └─ No → 外部システム(WebSocket、タイマー、DOM)との同期ですか?
            └─ Yes → useEffectを使用
            └─ No → propsやstateから計算できますか?
                      └─ Yes → 直接計算(または useMemo)
                      └─ No → 本当にuseEffectが必要か再検討

まとめ

今回のリファクタリングで学んだことは:

  1. 「自動的に」は便利だが、追跡しにくい

    • useEffectの依存配列が変わるたびに発火する挙動は、一見便利だが「いつ」「なぜ」が分かりにくくなる
  2. 明示的なトリガーはコードを読みやすくする

    • ボタンハンドラーに処理を書けば、「このボタンを押したらこの処理が走る」が一目瞭然
  3. React公式も「不要なEffectを削除しよう」と言っている

コードレビューで「useEffectじゃなくてボタン押した後の処理でできないかな?」と言われたら、ぜひ一度検討してみてください。コードが劇的に読みやすくなるかもしれません。

参考リンク

2
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
2
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?