はじめに
Reactの useEffect で外部への副作用(APIコール、親への通知、グローバルstateの更新など)を発火する際、初回マウント時はスキップしたいというのはよくあるパターンです。
const isInitialRender = useRef(true);
useEffect(() => {
if (isInitialRender.current) {
isInitialRender.current = false;
return; // 初回はスキップ
}
onSomethingChanged(value); // 2回目以降だけ発火
}, [value, onSomethingChanged]);
このパターン、実はマウント直後の連鎖的な依存変更には対応できません。
本記事では、この落とし穴と setTimeout(0) を使った解決策を紹介します。
useRef(true) で初回スキップが効かないケース
問題が起きる構造
ParentComponent
├── ChildA ← useEffectでonChange発火 → Redux state更新
├── ChildB ← Redux state変更で再レンダー → useEffectが再実行
└── ChildC ← 同上
各Childが以下のようなhookを使っているとします。
function useValueSync(value: SomeType, onChange?: (v: SomeType) => void) {
const isInitialRender = useRef(true);
useEffect(() => {
if (!onChange) return;
if (isInitialRender.current) {
isInitialRender.current = false;
return;
}
if (hasChanged(value)) {
onChange(value); // 親のstateを更新
}
}, [value, onChange]);
}
何が起きるか
[マウント時]
ChildA: useEffect実行 → isInitialRender=true → スキップ ✓ → false に設定
[同じtick内で依存が変わる]
※ onChangeがインライン関数で参照が変わる、
または他のstateの変更でvalueが変わる等
ChildA: useEffect再実行 → isInitialRender=false → onChange発火!💥
→ 親のstate更新 → ChildB, ChildCが再レンダー
→ ChildB: useEffect再実行 → onChange発火!💥
→ ChildC: useEffect再実行 → onChange発火!💥
isInitialRender は最初の1回のuseEffect実行しかスキップしません。同じtick内で依存が変わって再実行されると、もうガードが外れています。
依存が変わる典型的なパターン
1. インライン関数の参照変更
// 親コンポーネント
<Child onChange={(v) => dispatch(updateAction(v))} />
// ↑ 毎レンダーで新しい参照
2. 連鎖的なstate更新
ChildAのonChange → Redux dispatch → store更新
→ ChildBのprops(value)が変化 → useEffect再実行
→ ChildBのonChange → Redux dispatch → store更新
→ ChildCも...
3. 親の再レンダーによるprops変更
ChildAのonChange → 親が再レンダー
→ ChildB, ChildCに新しいpropsが渡される → useEffect再実行
解決策: tick-basedの安定化ガード
function useValueSync(value: SomeType, onChange?: (v: SomeType) => void) {
const isStabilized = useRef(false);
// マウント後、次のtickで安定化完了
useEffect(() => {
const timer = setTimeout(() => {
isStabilized.current = true;
}, 0);
return () => {
clearTimeout(timer);
isStabilized.current = false;
};
}, []);
useEffect(() => {
if (!onChange || !isStabilized.current) return;
if (hasChanged(value)) {
onChange(value);
}
}, [value, onChange]);
}
なぜ setTimeout(0) で解決できるのか
JavaScriptのイベントループの仕組みがポイントです。
[現在のtick(= 1つのmacrotask + その後のmicrotask)]
│
├── React render(同期)
├── useEffect 実行(同期)
├── Redux dispatch → reducer実行(同期)
├── React 再レンダー(同期 ※React 18のbatching)
├── useEffect 再実行(同期)
├── ... 連鎖的な再レンダー・useEffect(同期)
│
└── ここまで全て「現在のtick」
[次のtick]
└── setTimeout(0) のコールバック ← ここで isStabilized = true
setTimeout(0) はmacrotaskキューに入ります。現在のtick内の同期処理(Reactのレンダリング・useEffect・Redux dispatch)が全て完了した後に初めて実行されます。
つまり:
| タイミング | isInitialRender |
isStabilized |
|---|---|---|
| useEffect 初回実行 |
true → スキップ |
false → スキップ |
| 同じtick内の再実行(2回目) |
false → 通過してしまう
|
false → スキップ |
| 同じtick内の再実行(3回目) |
false → 通過してしまう
|
false → スキップ |
| 次のtick以降(ユーザー操作) |
false → 通過 |
true → 通過 |
マウント直後の連鎖的な再実行を回数に関係なく全てブロックし、ユーザー操作による変更だけを通すのがこのパターンの強みです。
ユーザー操作への影響は?
「次のtickまでブロック」と聞くと、ユーザー操作が遅延しないか心配になるかもしれません。
結論:影響はありません。
ユーザーの操作(クリック、入力、ファイルアップロードなど)は必ず別のmacrotaskとして処理されます。コンポーネントのマウントと同じtick内にユーザー操作が入り込むことはありえないため、isStabilized が true になった後に必ず処理されます。
テストの書き方
setTimeout(0) を使うため、テストでは jest.useFakeTimers() と act() でタイマーを進める必要があります。
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('マウント直後の依存変更ではonChangeが発火しない', () => {
const onChange = jest.fn();
const { rerender } = renderHook(
(value) => useValueSync(value, onChange),
{ initialProps: initialValue },
);
// 安定化前に値を変更
rerender(newValue);
expect(onChange).not.toHaveBeenCalled(); // ブロックされる
// 安定化完了
act(() => { jest.runAllTimers(); });
// 安定化後に値を変更
rerender(anotherValue);
expect(onChange).toHaveBeenCalled(); // 正常に発火
});
isInitialRender vs isStabilized の使い分け
| ケース | 推奨 |
|---|---|
| 依存が初回以降変わらない単純なhook |
isInitialRender で十分 |
| 同じhookが複数インスタンス存在し、互いのonChangeがstateを連鎖更新する |
isStabilized を使う |
| onChangeがインライン関数で参照が不安定 |
isStabilized を使う |
| コンポーネントがタブ切替等で頻繁にアンマウント→再マウントされる |
isStabilized を使う |
まとめ
-
useRef(true)による初回スキップは、最初の1回のuseEffect実行しか防げない - マウント直後に依存が連鎖的に変わると、同じtick内でuseEffectが複数回再実行され、ガードをすり抜ける
-
setTimeout(0)を使えば、マウント安定化期間中の全てのuseEffect再実行をブロックできる - ユーザー操作は必ず別のmacrotaskなので、正常な動作に影響しない
- 複数コンポーネントが互いのonChangeでstate更新を連鎖させる構造では、このパターンが特に有効