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

useRefの初回スキップでは防げない ― useEffectがマウント直後に連鎖発火する問題と対策

1
Posted at

はじめに

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内にユーザー操作が入り込むことはありえないため、isStabilizedtrue になった後に必ず処理されます。

テストの書き方

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更新を連鎖させる構造では、このパターンが特に有効
1
0
1

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