All files / hooks/use-interval/src useInterval.ts

100% Statements 42/42
100% Branches 25/25
100% Functions 10/10
100% Lines 41/41

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169                                                                                                                                                                      30x       30x       30x 30x 30x 30x       30x 30x 30x 30x 30x     30x 6x 2x   4x 4x 4x 1x       30x 4x 2x   2x 2x     30x 2x 1x   1x           30x 28x 9x   19x 14x   19x         30x 30x 20x           2x 2x           30x   30x 27x        
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 
/**
 * Interval delay in milliseconds. `null`/`undefined` disables the interval.
 */
export type IntervalDelay = number | null | undefined;
 
/**
 * Callback invoked on each interval tick.
 */
export type UseIntervalCallback = () => void;
 
/**
 * Options for {@link useInterval}.
 */
export interface UseIntervalOptions {
  /**
   * Execute the callback immediately when the interval (re)starts, then again on
   * each interval. On auto-start this fires once on mount (StrictMode-safe).
   * @default false
   */
  immediate?: boolean;
  /**
   * Start the interval automatically on mount. When `false`, call `start()`.
   * @default true
   */
  autoStart?: boolean;
}
 
/**
 * Return value of {@link useInterval}.
 */
export interface UseIntervalReturn {
  /** Start the interval (idempotent while already running). */
  start: () => void;
  /** Stop the interval (idempotent while already stopped). */
  stop: () => void;
  /** Toggle between running and stopped. */
  toggle: () => void;
  /** Whether the interval is currently ticking (started AND a valid delay). */
  isRunning: boolean;
}
 
/**
 * A declarative, SSR-safe `setInterval` for React with start/stop/toggle
 * controls. The callback is always read through a ref, so changing it never
 * restarts the interval, and the interval is cleared automatically on unmount.
 *
 * Passing `null`/`undefined` as the delay disables the interval; changing the
 * delay restarts it with the new value. A negative delay is treated as `0`.
 *
 * @param callback - Function to run on each tick.
 * @param delay - Interval in ms, or `null`/`undefined` to disable.
 * @param options - `immediate` and `autoStart` behavior.
 * @returns `{ start, stop, toggle, isRunning }`.
 *
 * @example
 * ```tsx
 * // Poll every 5 seconds
 * useInterval(() => {
 *   fetchData().then(setData);
 * }, 5000);
 * ```
 *
 * @example
 * ```tsx
 * // Countdown that stops itself at zero (disable via null delay)
 * const [count, setCount] = useState(10);
 * useInterval(() => setCount((c) => c - 1), count > 0 ? 1000 : null);
 * ```
 *
 * @example
 * ```tsx
 * // Manual pause/resume
 * const { toggle, isRunning } = useInterval(() => tick(), 1000);
 * return <button onClick={toggle}>{isRunning ? "Pause" : "Resume"}</button>;
 * ```
 */
export function useInterval(
  callback: UseIntervalCallback,
  delay: IntervalDelay,
  options: UseIntervalOptions = {}
): UseIntervalReturn {
  const { immediate = false, autoStart = true } = options;
 
  // The user's start/stop intent. The interval only ticks when this is true
  // AND a valid delay is set (see `isRunning`).
  const [started, setStarted] = useState(autoStart);
 
  // Latest values in refs so start/stop/toggle stay identity-stable and the
  // interval never re-subscribes just because the callback changed.
  const callbackRef = useRef(callback);
  const delayRef = useRef(delay);
  const immediateRef = useRef(immediate);
  const startedRef = useRef(started);
 
  // Keep the refs current post-commit (never mutate during render — that would
  // be unsafe under concurrent rendering / StrictMode).
  useEffect(() => {
    callbackRef.current = callback;
    delayRef.current = delay;
    immediateRef.current = immediate;
    startedRef.current = started;
  });
 
  const start = useCallback(() => {
    if (startedRef.current) {
      return; // idempotent: already running
    }
    startedRef.current = true;
    setStarted(true);
    if (immediateRef.current && delayRef.current != null) {
      callbackRef.current();
    }
  }, []);
 
  const stop = useCallback(() => {
    if (!startedRef.current) {
      return; // idempotent: already stopped
    }
    startedRef.current = false;
    setStarted(false);
  }, []);
 
  const toggle = useCallback(() => {
    if (startedRef.current) {
      stop();
    } else {
      start();
    }
  }, [start, stop]);
 
  // The interval itself. Symmetric setup/cleanup so a StrictMode double-invoke
  // (or a delay change) correctly tears down and re-establishes the timer.
  useEffect(() => {
    if (!started || delay == null) {
      return;
    }
    const id = setInterval(() => {
      callbackRef.current();
    }, Math.max(0, delay));
    return () => clearInterval(id);
  }, [started, delay]);
 
  // Immediate execution for the auto-start path, fired exactly once on mount
  // (ref-guarded so a StrictMode double-invoke doesn't fire it twice).
  const didAutoImmediateRef = useRef(false);
  useEffect(() => {
    if (
      autoStart &&
      immediate &&
      delay != null &&
      !didAutoImmediateRef.current
    ) {
      didAutoImmediateRef.current = true;
      callbackRef.current();
    }
    // Mount-only; guarded by the ref above.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
 
  const isRunning = started && delay != null;
 
  return useMemo(
    () => ({ start, stop, toggle, isRunning }),
    [start, stop, toggle, isRunning]
  );
}