副作用関数の中での「if文」――その誘惑と、アーキテクトが直面する現実
こんにちは。日夜、再レンダリングの嵐とメモリーリークの荒波を航海しているフロントエンド・アーキテクトの皆さん。
Reactのコードレビューをしていて、もっとも頭が痛くなる瞬間の一つが、`useEffect`の内部に鎮座する「条件分岐(if文)」だ。
useEffect(() => {
if (!userId) return; // ここで弾く
// 本丸の処理
fetchUserData(userId);
}, [userId, fetchUserData]);
「おっ、ちゃんと早期リターンしていて安全そうだな」――そう思ったあなた。ちょっと待ってほしい。
その`if`文、本当にそこで書くべきものだったのだろうか? それとも、依存配列の制御から目を背けた結果の「場当たり的な延命措置」なのだろうか?
今回は、副作用関数の内部における条件分岐の是非について、Reactのファイバーツリーの挙動、V8エンジンのメモリ効率、そして非同期処理の競合(Race Condition)という泥臭い現実を踏まえながら、徹底的に深掘りしていこう。
—
1. なぜエンジニアは `useEffect` 内で `if` 文を書きたがるのか?
人間は本能的に「ガード句」が好きだ。防衛的プログラミングの観点から言えば、関数に入ってきた瞬間に入力値をバリデーションし、不正であれば即座に弾くのは美徳とされている。
しかし、Reactの `useEffect` は通常のJavaScriptの関数とは根本的に異なる。これは「コンポーネントの状態(State/Props)と、外部世界(DOM、API、WebSocketsなど)を同期させるための同期的・宣言的なエスケープハッチ」だ。
ここに `if` 文を持ち込む動機はだいたい以下の3つに集約される。
1. 初期値の欠落への恐怖: マウント直後など、まだデータが揃っていない(`null` や `undefined`)状態でフェッチが走るのを防ぎたい。
2. 依存配列の無限ループ回避: 依存配列にオブジェクトや関数を入れると無限ループするから、とりあえず `id` だけ入れて、中身で `if` 判定する。
3. イベントハンドラと副作用の混同: 「ボタンが押されたとき」「フラグがtrueのとき」といったイベント駆動のメンタルモデルを、そのまま `useEffect` に持ち込んでしまう。
だが、これらは往々にして、Reactのライフサイクルモデルに対する「誤解」や「敗北」のサインなのだ。
—
2. アーキテクチャの観点:依存配列 vs 副作用内 if文
では、依存配列での制御と、副作用内での `if` 文の制御では、何がどう違うのか。ブラウザのランタイムとReactのスケジューラ視点から解剖してみよう。
依存配列による制御(宣言的アプローチ)
// パターンA: 依存配列で完全にコントロールする
useEffect(() => {
const controller = new AbortController();
fetchData(userId, { signal: controller.signal });
return () => controller.abort();
}, [userId]); // userIdが変わった時だけに「関心」を絞る
このアプローチの美しさは、「いつ副作用が作られ、いつ破棄されるべきか」が宣言的に定義されている点にある。Reactは `userId` が変化したこと検知すると、古い副作用のクリーンアップ関数を実行し、新しい副作用をセットアップする。ファイバーのコミットフェイズにおけるフックの依存関係チェックは非常に高速であり、無駄な関数実行コストが発生しない。
副作用内部の `if` による制御(手続き的アプローチ)
// パターンB: 副作用の中で if で弾く
useEffect(() => {
if (!isActive || !userId) {
return; // 毎回エフェクトが発火し、中で何もせずに抜ける
}
fetchData(userId);
}, [isActive, userId, fetchData]); // 依存配列が肥大化しやすい
このパターンBには、いくつかの深刻な見落としが潜んでいる。
1. 不要なエフェクトのスケジュール(オーバーヘッド):
`isActive` や `userId` が変化するたびに、Reactは「副作用がスケジュールされた」と判断し、レンダリング後のコミットフェイズでコールバック関数をわざわざ呼び出す。その内部で `if (!isActive)` にヒットして即座に `return` したとしても、関数コールのオーバーヘッドやフックの内部的な差分チェックのコストは発生している。これがアプリケーション全体で何百個も存在すると、確実なフレームドロップの原因になる。
2. クリーンアップ関数の罠:
もし `if` の手前や途中でリターンしてしまうと、意図したクリーンアップ(タイマーのクリアやイベントリスナーの削除など)がスキップされたり、逆に前のクロージャーを掴み続けたりする「ゾンビ副作用」の温床になる。
—
3. 実践:最悪の競合バグを防ぐための設計思想
では、単なる「早期リターン」ではなく、本当に条件によって挙動を変えなければならない複雑な非同期処理はどう扱うべきか?
ここで、現場でよくある「ユーザーIDの切り替えに伴う非同期処理の競合(Race Condition)」を例に取ろう。
❌ やってはいけない:副作用内の `if` と不完全なクリーンアップ
// 悪い例:副作用の中で頑張ってifで制御しようとした結果、競合やバグの温床に
function UserProfile({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
// 依存配列に色々入れるのが面倒だから、中でifでガードするスタイル
if (!userId) {
setData(null);
return;
}
let isCancelled = false;
async function load() {
const result = await fetchUser(userId);
// 非同期の途中でuserIdが変わっているかもしれないが、
// 内部のifやフラグ管理が複雑化して破綻しやすい
if (!isCancelled) {
setData(result);
}
}
load();
return () => {
isCancelled = true;
};
}, [userId]); // ここは一見シンプルに見えるが…
}
このコードの何が問題かといえば、「副作用のスコープ外の状態(コンポーネントのライフサイクルや他のProps)に依存した条件分岐」を副作用の内部に隠蔽している点だ。コードが読みにくくなり、将来のメンテナンサーが `if` の条件を追加・変更した際に、クリーンアップ漏れを起こす確率が跳ね上がる。
⭕ あるべき姿:カスタムフックと関心の分離による解決
もし副作用の内部が複雑な条件分岐で汚染されそうになったら、それは「コンポーネントの責務が肥大化している」か「カスタムフックに切り出すべきサイン」だ。
以下のように、データの取得ロジックとトリガー条件を綺麗に分離するのが、堅牢なアーキテクチャへの近道となる。
import { useState, useEffect } from ‘react’;
// 1. データフェッチの責務を持つカスタムフック
// ここでは余計なif文を書かず、渡されたパラメータに対して忠実に動く
function useUserData(userId) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// そもそも「IDが存在しない」というビジネスロジックは、
// フックを呼び出す側(コンポーネント)で制御し、
// ここには「有効なIDが渡されてきた」という前提を持ち込むのが一番美しい。
if (!userId) {
setData(null);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(json => {
setData(json);
setLoading(false);
})
.catch(err => {
if (err.name !== ‘AbortError’) {
setError(err);
setLoading(false);
}
});
return () => {
controller.abort();
};
}, [userId]);
return { data, loading, error };
}
// 2. コンポーネント側では、表示の切り替え(条件分岐)に集中する
function UserProfile({ userId }) {
// そもそも userId がない場合はコンポーネントを早期リターン、
// あるいはプレースホルダーを表示する設計にする
if (!userId) {
return
ユーザーが選択されていません。
;
}
const { data, loading, error } = useUserData(userId);
if (loading) return
読み込み中…
;
if (error) return
エラーが発生しました。
;
if (!data) return null;
return (
{data.name}
{data.email}
);
}
この設計の優れているところは、「いつデータを取得するか(UIのライフサイクル)」と「どうデータを取得するか(副作用のライフサイクル)」の境界線が明確に引かれている点だ。
副作用関数の内部に複雑な `if` 文を持ち込まなくても、コンポーネント側のレンダリングツリーの構造やカスタムフックの引数制御によって、不毛な副作用の実行そのものを未然に防いでいる。
—
4. まとめ:ギークな視点から見た「良い副作用」の条件
Reactのパフォーマンスチューニングやメモリ効率の最適化において、最もコストが高いのは「無駄な再レンダリング」と「解放されない非同期リソース(メモリリーク)」だ。
副作用関数の中での `if` 文の乱用は、一見すると安全なバリデーションに見えて、その実「Reactの宣言的なスケジュール機構に対する逃避」に過ぎないことが多い。
真に堅牢で、V8エンジンやReactファイバーに愛されるコードを書くための原則を最後に置いておこう。
1. 副作用を走らせたくないなら、そもそも依存配列やコンポーネントの構造でその状態を作らない(親でガードする、適切なカスタムフックに閉じ込める)。
2. 副作用関数内部の `if` は、あくまで「リソースのクリーンアップ」や「早期のabort判定」など、技術的な防衛策に限定する。 ビジネスロジックの条件分岐を持ち込まない。
3. 依存配列は嘘をつかない。 ESLintの `react-hooks/exhaustive-deps` を黙らせるために副作用内で `if` を使うのではなく、依存関係の設計そのものを見直す。
泥臭い非同期の競合やメモリリークの嵐をくぐり抜けてきた我々だからこそ、美しく、かつ強靭なアーキテクチャをコードに宿していこう。
それでは、良きReactライフを!

コメント