setTimeout/setIntervalの副作用を責任ある構造で設計する

Reactでは、setTimeoutsetInterval を用いた時間ベースの処理がよく登場します。これらはJavaScriptにおける基本的なAPIですが、Reactのライフサイクルと適合しない使い方や、クリーンアップ漏れ・責務の過集中といった構造上の問題を多く含みます。

タイマー系副作用は小さなロジックで済むように見えて、実際には 再レンダリング・状態との整合性・停止制御・イベント管理 など多様な責務を内包するため、構造設計と責任分離の判断を疎かにすべきではありません。

本記事では、setTimeout / setIntervalの使用における設計上の落とし穴と、責任ある構造への再構成をレビューアー視点で解説します。

初期実装でよく見かけるタイマー処理の集中構造

setTimeoutと状態更新が密結合したコード
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が再定義された場合や、レンダリングタイミングにより不要な再発火のリスクがある
Comment
@Reviewer: 副作用が単一のEffect内に閉じており、処理制御の外部化ができていません。ユーザーアクション等による中断処理や他のフローとの連携を考慮し、タイマー制御を責務単位に分離する構造を検討してください。
setTimeout / setIntervalとは

setTimeoutは指定ミリ秒後に1度だけ関数を実行し、setIntervalは指定ミリ秒ごとに繰り返し関数を実行するJavaScriptのAPI。いずれもIDが返るため、clearTimeout / clearIntervalで明示的に停止できる。

責務を切り分けたタイマー管理Hookの設計

状態とタイマー制御の責務を分離した構造の一例を以下に示します。

useAutoClose.ts
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) };
};
AutoCloseMessage.tsx
export const AutoCloseMessage = () => {
  const { visible, reset } = useAutoClose(3000);

  return (
    <>
      {visible && <p>表示中</p>}
      <button onClick={reset}>再表示</button>
    </>
  );
};

この構造では以下の改善が見られます。

  • タイマー処理と表示ロジックが分離されている
  • resetにより制御が可能になっており、再表示の責務が明示されている
  • タイマー設定値(duration)が引数に明示されており、再利用性が高い

:::comment @Reviewer: 副作用のライフサイクルとUI状態が責任ごとに分割されており、構造が読みやすくなっていますね。汎用Hookとして切り出されていることで、他の表示系UIにも適用しやすい構成です。 :::

setIntervalと再レンダリングの問題構造

intervalによるログ処理(非推奨構造)
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制御

useIntervalCounter.ts
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;
};
Heartbeat.tsx
export const Heartbeat = () => {
  const count = useIntervalCounter(1000);
  return <p>tick: {count}</p>;
};

このように構造を切り分けることで、

  • 再レンダリングのトリガーを意図的に制御
  • カウント処理をuseRefに保持し、UI更新との独立性を確保
  • 状態更新が不要な場合にもタイマー制御を柔軟に拡張可能

setTimeout / setIntervalの構造差異

UML Diagram

レビューで確認すべき設計観点

タイマー系APIの使用箇所に対し、レビューアーとしては以下の観点で確認を行うべきです。

  • setTimeoutsetIntervalの処理がコンポーネントのUI責務と混在していないか
  • 中断・再開・再設定といった制御責務が設計に含まれているか
  • クリーンアップ処理が明示され、リソースリークの心配がない構造か
  • タイマーの目的がUI制御かロジック制御かで構造が分かれているか

シンプルな1行から始まるsetTimeoutも、成長する機能と共に副作用の本質的な設計課題が顕在化します。Reactで副作用管理を担う責任として、タイマー処理にも責務設計を適用することが求められます。