setTimeout/setIntervalの副作用を責任ある構造で設計する
setTimeout/setIntervalの副作用を責任ある構造で設計する
Reactでは、setTimeout
や setInterval
を用いた時間ベースの処理がよく登場します。これらはJavaScriptにおける基本的なAPIですが、Reactのライフサイクルと適合しない使い方や、クリーンアップ漏れ・責務の過集中といった構造上の問題を多く含みます。
タイマー系副作用は小さなロジックで済むように見えて、実際には 再レンダリング・状態との整合性・停止制御・イベント管理 など多様な責務を内包するため、構造設計と責任分離の判断を疎かにすべきではありません。
本記事では、setTimeout / setIntervalの使用における設計上の落とし穴と、責任ある構造への再構成をレビューアー視点で解説します。
初期実装でよく見かけるタイマー処理の集中構造
export const AutoCloseMessage = () => {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 3000);
return () => {
clearTimeout(timer);
};
}, []);
return visible ? <p>メッセージ表示中</p> : null;
};
この実装はシンプルで動作も一見正しく見えますが、実際には以下のような問題を含みます。
- タイマーと状態更新が密接に結合しており、テストや再利用が困難
- 3秒後に状態変更が起こるにも関わらず、ユーザー操作との協調処理が考慮されていない
setVisible
が再定義された場合や、レンダリングタイミングにより不要な再発火のリスクがある
@Reviewer: 副作用が単一のEffect内に閉じており、処理制御の外部化ができていません。ユーザーアクション等による中断処理や他のフローとの連携を考慮し、タイマー制御を責務単位に分離する構造を検討してください。
setTimeout
は指定ミリ秒後に1度だけ関数を実行し、setInterval
は指定ミリ秒ごとに繰り返し関数を実行するJavaScriptのAPI。いずれもIDが返るため、clearTimeout
/ clearInterval
で明示的に停止できる。
責務を切り分けたタイマー管理Hookの設計
状態とタイマー制御の責務を分離した構造の一例を以下に示します。
export const useAutoClose = (duration: number) => {
const [visible, setVisible] = useState(true);
useEffect(() => {
const id = setTimeout(() => setVisible(false), duration);
return () => clearTimeout(id);
}, [duration]);
return { visible, reset: () => setVisible(true) };
};
export const AutoCloseMessage = () => {
const { visible, reset } = useAutoClose(3000);
return (
<>
{visible && <p>表示中</p>}
<button onClick={reset}>再表示</button>
</>
);
};
この構造では以下の改善が見られます。
- タイマー処理と表示ロジックが分離されている
reset
により制御が可能になっており、再表示の責務が明示されている- タイマー設定値(duration)が引数に明示されており、再利用性が高い
:::comment @Reviewer: 副作用のライフサイクルとUI状態が責任ごとに分割されており、構造が読みやすくなっていますね。汎用Hookとして切り出されていることで、他の表示系UIにも適用しやすい構成です。 :::
setIntervalと再レンダリングの問題構造
export const Heartbeat = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>tick: {count}</p>;
};
このコードには以下の設計課題が潜んでいます。
- 1秒ごとに状態更新 → コンポーネント再レンダリングが発生し、パフォーマンスに影響
useRef
を使ったカウント処理に比べてUIに不要な更新が伝播してしまう- ライフサイクルや条件付き停止などが考慮されていないため、制御性が低い
状態更新と再描画の分離:useRef+Interval制御
export const useIntervalCounter = (intervalMs: number) => {
const countRef = useRef(0);
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => {
countRef.current++;
setTick((t) => t + 1); // 描画のトリガーに限定
}, intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return countRef.current;
};
export const Heartbeat = () => {
const count = useIntervalCounter(1000);
return <p>tick: {count}</p>;
};
このように構造を切り分けることで、
- 再レンダリングのトリガーを意図的に制御
- カウント処理を
useRef
に保持し、UI更新との独立性を確保 - 状態更新が不要な場合にもタイマー制御を柔軟に拡張可能
setTimeout / setIntervalの構造差異
レビューで確認すべき設計観点
タイマー系APIの使用箇所に対し、レビューアーとしては以下の観点で確認を行うべきです。
setTimeout
やsetInterval
の処理がコンポーネントのUI責務と混在していないか- 中断・再開・再設定といった制御責務が設計に含まれているか
- クリーンアップ処理が明示され、リソースリークの心配がない構造か
- タイマーの目的がUI制御かロジック制御かで構造が分かれているか
シンプルな1行から始まるsetTimeoutも、成長する機能と共に副作用の本質的な設計課題が顕在化します。Reactで副作用管理を担う責任として、タイマー処理にも責務設計を適用することが求められます。