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

React 18や19でボタン連打対策にsetTimeoutを使う前に知っておきたいこと|Concurrent Featuresとテスト環境の関係

Last updated at Posted at 2026-01-05

この記事について

React 18/19ではボタン押下にsetTimeoutを使う際、知っておくべき注意点があります。
それは、タイマーを適切にクリーンアップしないとテスト環境で問題が発生するということです。この問題はReact 18から内部の並行処理の仕組みが変わったことによるもので、テストのconfigファイルでjsdomを設定済みでも window is not defined エラーが発生したりします。
この記事ではその原因と解決策を解説していきます。

こんな方に読んでほしい

  • Vue.js や Angular から React 18+ を学び始めた方 - React 特有の注意点を事前に知っておきたい
  • React 17 以前の経験者で React 18+ を学んでいる方 - バージョンアップで変わったポイントを押さえたい
  • すでにこのエラーに遭遇している方 - window is not defined の原因と解決策を知りたい
この記事でエラー解消するかの判断基準

以下の両方に当てはまりますか?

  1. React 18 または 19 を使用している
  2. jsdom が設定されている(以下のいずれか)
    • vitest.config.ts(または .js)に environment: 'jsdom'
    • テストファイルの先頭に // @vitest-environment jsdom

両方 YES → この記事が当てはまります
React 17 以前この記事のエラーは発生しません(React 内部が window.event を参照しないため)
jsdom 未設定 → jsdom の設定が必要です。以下の公式ドキュメントを参照してください

問題の症状

エラーが発生するコード

以下はボタンの連打防止を実装したコンポーネントです。

  • クリックするとボタンを無効化(disabled
  • 一定時間(waitTime)経過後に再度有効化
// Button.tsx
export const Button = ({ waitTime = 1500 }) => {
  const [isDisabled, setIsDisabled] = useState(false);

  const handleClick = () => {
    setIsDisabled(true);
    setTimeout(() => setIsDisabled(false), waitTime);
  };

  return (
    <button onClick={handleClick} disabled={isDisabled}>
      Click
    </button>
  );
};

一見問題なさそうですが、このコードを Vitest でテストすると以下のエラーが発生することがあります。

テストは全て PASS なのに Unhandled Errors が報告される

jsdom 環境を設定している場合でも、以下のようなエラーが発生することがあります。

 ✓ src/test/components/Button.test.tsx (5 tests) 234ms

⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯

ReferenceError: window is not defined
 ❯ getCurrentEventPriority node_modules/react-dom/cjs/react-dom.development.js:10993:22
 ❯ requestUpdateLane node_modules/react-dom/cjs/react-dom.development.js:25456:19
 ❯ dispatchSetState node_modules/react-dom/cjs/react-dom.development.js:17467:14
 ❯ Timeout._onTimeout src/components/Button.tsx:26:22

 Test Files  1 passed (1)
      Tests  5 passed (5)
     Errors  1 error          ← これ

特徴

  • テストファイルは PASS
  • テスト数も正常にカウントされている
  • しかし Errors が 1 以上
  • エラー箇所が react-dom の内部

このエラーの厄介な点

  • テストは成功しているのにエラーが出る
  • エラーが出たり出なかったりする(不安定)
  • エラー箇所が実装のコードではなく react-dom の内部

原因の特定

エラーログを読む

スタックトレースを下から順に見ていきます。

ReferenceError: window is not defined
 ❯ getCurrentEventPriority node_modules/react-dom/cjs/react-dom.development.js:10993:22
 ❯ requestUpdateLane node_modules/react-dom/cjs/react-dom.development.js:25456:19
 ❯ dispatchSetState node_modules/react-dom/cjs/react-dom.development.js:17467:14
 ❯ Timeout._onTimeout src/components/Button.tsx:15:22
    13|   const handleClick = () => {
    14|     setIsDisabled(true);
    15|     setTimeout(() => setIsDisabled(false), waitTime);
       |                      ^
意味
src/components/Button.tsx:15:22 実際の原因箇所(一番下 = 起点)
Timeout._onTimeout setTimeout のコールバックから発火
dispatchSetState setState が呼ばれた
requestUpdateLane 更新レーン(優先度)を要求
getCurrentEventPriority React が更新優先度を取得しようとした

スタックトレースの一番下が原因箇所です。setTimeout のコールバックがテスト環境(jsdom)の破棄後に発火すると、スタックトレースで示されている setIsDisabled から React の setState が呼ばれ、setStatewindow を参照しようとしてエラーになります(詳細は後述)。

なぜ React の setState が window を参照するのか

React 18 から導入された Concurrent Features(並行処理機能)が起因となっています。

公式ブログから引用

メインスレッドをブロックせずにバックグラウンドで次の画面を用意しておけるようになります。つまり、大きなレンダー作業の最中でもユーザの入力に UI が即座に反応できるということであり、ユーザ体験がスムースになります。

この「ユーザの入力に即座に反応」を実現するために、React は setState 時に window.event を参照して現在のイベントの種類を判定しています。具体的には、スタックトレースにも登場した getCurrentEventPriority 関数で以下のように処理しています。

// react-dom 内部(簡略化)
function getCurrentEventPriority() {
  const currentEvent = window.event; // ← ここで window を参照
  if (currentEvent === undefined) {
    return DefaultEventPriority;
  }
  return getEventPriority(currentEvent.type);
}

通常のブラウザ環境では window は常に存在するため問題ありません。しかし Vitest のテスト環境では、テスト終了後に jsdom が提供する window が破棄されます。そのタイミングでこの関数が呼ばれると、エラーになります。

React 19 でも同様

React 19 では関数名が resolveUpdatePriority に変更されていますが、内部で window.event を参照する動作は変わっていません。

// React 19 の react-dom 内部(簡略化)
function resolveUpdatePriority() {
  // ...省略...
  const currentEvent = window.event; // ← 同様に window を参照
  if (currentEvent === undefined) {
    return DefaultEventPriority;
  }
  return getEventPriority(currentEvent.type);
}

そのため、この記事で説明している問題と解決策は React 19 でも引き続き当てはまります

エラーが不安定な理由

このエラーはタイミングに依存するため、毎回発生するとは限りませんsetTimeout のコールバックが jsdom 環境の破棄前に発火すればエラーは出ず、破棄後に発火すると出ます。テスト実行のたびに結果が変わる可能性があり、いわゆる Flaky Test(不安定なテスト)の原因になります。筆者の感覚ではテスト実行ファイルが少ないと発生しにくいです。

false positive tests とは?

Vitest のメッセージに出てきた警告についても触れておきます。

This might cause false positive tests.

Vitest のソースコード(logger.ts)からも分かる通り、この警告と共に以下のメッセージも表示されます。

Resolve unhandled errors to make sure your tests are not affected.

つまり、「unhandled error によってテスト結果が信頼できなくなっている可能性がある」 という警告です。

解決策

useEffect のクリーンアップで clearTimeout することで、テスト環境破棄後のコールバック発火を防ぎます。

Before(❌ 悪い例)

export const Button = ({ waitTime = 1500 }) => {
  const [isDisabled, setIsDisabled] = useState(false);

  const handleClick = () => {
    setIsDisabled(true);
    // ⚠️ クリーンアップなし - テスト環境破棄後にコールバックが発火する可能性
    setTimeout(() => setIsDisabled(false), waitTime);
  };

  return (
    <button onClick={handleClick} disabled={isDisabled}>
      Click
    </button>
  );
};

After(✅ 良い例)

export const Button = ({ waitTime = 1500 }) => {
  const [isDisabled, setIsDisabled] = useState(false);

  useEffect(() => {
    if (!isDisabled) return;

    const timerId = setTimeout(() => setIsDisabled(false), waitTime);
    return () => clearTimeout(timerId); // ✅ クリーンアップ
  }, [isDisabled, waitTime]);

  const handleClick = () => {
    setIsDisabled(true);
  };

  return (
    <button onClick={handleClick} disabled={isDisabled}>
      Click
    </button>
  );
};

ポイント

  • setTimeoutuseEffect 内に移動
  • クリーンアップ関数で clearTimeout を呼ぶ
  • コンポーネントがアンマウントされると自動的にタイマーがキャンセルされる

React 公式ドキュメントでも推奨されているパターン

React 公式ドキュメントの useEffect では、タイマーを「外部システム」として useEffect で管理することが推奨されています。

An Effect lets you keep your component synchronized with some external system (like a chat service). Here, external system means any piece of code that's not controlled by React, such as:

  • A timer managed with setInterval() and clearInterval().
  • An event subscription using window.addEventListener() and window.removeEventListener().
  • A third-party animation library with an API like animation.start() and animation.reset().

この考え方に基づき、setTimeoutuseEffect 内で管理し、クリーンアップ関数で clearTimeout するのが適切なパターンです。

まとめ

やること 理由
setTimeout は useEffect 内で使う クリーンアップ関数で clearTimeout できる
clearTimeout でタイマーをキャンセル 環境破棄後のコールバック発火を防ぐ
// Before(❌)
const handleClick = () => {
  setIsDisabled(true);
  setTimeout(() => setIsDisabled(false), waitTime);
};

// After(✅)
useEffect(() => {
  if (!isDisabled) return;
  const timerId = setTimeout(() => setIsDisabled(false), waitTime);
  return () => clearTimeout(timerId);
}, [isDisabled, waitTime]);

const handleClick = () => {
  setIsDisabled(true);
};
React 19 では Actionsを利用することが可能

ボタンの連打防止で「非同期処理の完了を待つ」場合は、React 19 の Actions(useTransition)を使う方がシンプルです。

// React 19: useTransition を使った連打防止
function SubmitButton() {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      await submitData(); // 非同期処理
    });
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      送信
    </button>
  );
}

isPending が自動的に管理されるため、setTimeout + クリーンアップのパターンは不要になります。

詳細はこの記事では取り扱わないので、 useTransition を参照してください。

検証環境

この記事の内容は以下の環境で検証しました。

ツール バージョン
Node.js 20.19.6
vitest 1.1.0
react 18.2.0
react-dom 18.2.0
jsdom 23.0.1

※ React 19.2.3 でも同様のエラーが発生することを確認済み
※ React 17.0.2 ではこのエラーは発生しないことを確認済み

参考

ドキュメント

ソースコード

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