この記事について
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の原因と解決策を知りたい
この記事でエラー解消するかの判断基準
以下の両方に当てはまりますか?
- React 18 または 19 を使用している
-
jsdom が設定されている(以下のいずれか)
-
vitest.config.ts(または.js)にenvironment: 'jsdom' - テストファイルの先頭に
// @vitest-environment jsdom
-
両方 YES → この記事が当てはまります
React 17 以前 → この記事のエラーは発生しません(React 内部が window.event を参照しないため)
jsdom 未設定 → jsdom の設定が必要です。以下の公式ドキュメントを参照してください
- Vitest - Getting Started - 設定ファイル(vitest.config.ts)の作成方法
- Vitest Config - environment - environment オプションの詳細
-
Vitest Guide - Test Environment - ファイル単位での環境指定(
// @vitest-environment)
問題の症状
エラーが発生するコード
以下はボタンの連打防止を実装したコンポーネントです。
- クリックするとボタンを無効化(
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 が呼ばれ、setStateがwindow を参照しようとしてエラーになります(詳細は後述)。
なぜ 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>
);
};
ポイント
-
setTimeoutをuseEffect内に移動 - クリーンアップ関数で
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()andclearInterval().- An event subscription using
window.addEventListener()andwindow.removeEventListener().- A third-party animation library with an API like
animation.start()andanimation.reset().
この考え方に基づき、setTimeout も useEffect 内で管理し、クリーンアップ関数で 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 ではこのエラーは発生しないことを確認済み
参考
ドキュメント
- Vitest - Getting Started - 設定ファイル(vitest.config.ts)の作成方法
- Vitest Config - environment - environment オプションの詳細
-
Vitest Guide - Test Environment - ファイル単位での環境指定(
// @vitest-environment) - React v18.0 - React の並行処理機能とは? - Concurrent Features の解説
- Introducing Concurrent Mode (Experimental) - React 17 での実験的機能(※歴史的経緯の記録用ページ)
- React - useEffect > Usage > Connecting to an external system - タイマーを「外部システム」として useEffect で管理するパターン
- React - useTransition - React 19 の Actions を使った非同期処理の管理
-
MDN - setTimeout() -
setTimeoutの仕様 -
MDN - clearTimeout()
clearTimeoutの仕様 - 失敗しないはずのテストが時々失敗する問題を解決した話 - Swift/XCTest での同様の問題(非同期処理がテスト終了後に発火する Flaky テスト)
ソースコード
- vitest/packages/vitest/src/node/logger.ts - "false positive tests" 警告メッセージの実装箇所
-
react-dom/src/client/ReactDOMHostConfig.js (v18.2.0) -
getCurrentEventPriorityの実装(React 18) -
react-dom-bindings/src/client/ReactDOMUpdatePriority.js (v19.2.3) -
resolveUpdatePriorityの実装(React 19)