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で副作用管理を担う責任として、タイマー処理にも責務設計を適用することが求められます。
