この記事でいいたいことを、一言でまとめると
- 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が必要か再検討
まとめ
今回のリファクタリングで学んだことは:
-
「自動的に」は便利だが、追跡しにくい
- useEffectの依存配列が変わるたびに発火する挙動は、一見便利だが「いつ」「なぜ」が分かりにくくなる
-
明示的なトリガーはコードを読みやすくする
- ボタンハンドラーに処理を書けば、「このボタンを押したらこの処理が走る」が一目瞭然
-
React公式も「不要なEffectを削除しよう」と言っている
コードレビューで「useEffectじゃなくてボタン押した後の処理でできないかな?」と言われたら、ぜひ一度検討してみてください。コードが劇的に読みやすくなるかもしれません。