AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useInterval Hook: Managing Timers in React Components

Last updated:
useInterval Hook: Managing Timers in React Components

Build robust interval timers in React with useInterval. Handle closures, stale state, pausing, and cleanup with production-ready examples and complete TypeScript code.

# useInterval Hook: Managing Timers in React Components

Timers seem simple until you use them in React. A raw setInterval creates a closure that captures stale state, memory leaks from forgotten cleanup, and race conditions when dependencies change. Most developers encounter this when building a countdown timer and discover their component counts with yesterday's value, or a polling mechanism that fires requests based on outdated variables.

The useInterval hook abstracts interval management, handling the closure problem that makes React developers reach for Stack Overflow. It manages cleanup, handles pausing, and lets you access the latest state and props inside your interval callback without mental gymnastics.

# Table of Contents

  1. Why setInterval is Tricky in React
  2. Basic useInterval Implementation
  3. Advanced Patterns: Delays and Pausing
  4. Handling Stale Closures
  5. Practical Application Scenarios
  6. Performance and Cleanup
  7. FAQ

# Why setInterval is Tricky in React

The intuitive approach fails immediately:

javascript
// ❌ This has problems
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log(count); // ⚠️ Always logs 0!
      setCount(count + 1); // ⚠️ Counts in jumps: 0, 1, 0, 1...
    }, 1000);
  }, []); // Empty dependency array is wrong!

  return <div>Count: {count}</div>;
}

Three problems immediately emerge:

  1. Stale Closure: The callback captures count from the initial render. It always sees count = 0, even after state updates.
  2. Jumpy Increments: Each interval sets count based on the stale value, then the next render causes another re-render before the next interval fires.
  3. Memory Leak: No cleanup function, so intervals pile up with every component remount.

Adding dependencies makes it worse:

javascript
// ❌ This creates a new interval every render
useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(interval);
}, [count]); // ⚠️ Dependency array includes count!
// Result: New interval every time count changes = frequent cleanup/restart

A proper hook solves this elegantly using the updater function pattern.

# Basic useInterval Implementation

# TypeScript Version

typescript
import { useEffect, useRef } from 'react';

interface UseIntervalOptions {
  // Interval delay in milliseconds
  delay: number;
  // Whether to run the interval (false pauses it)
  enabled?: boolean;
}

export function useInterval(
  callback: () => void,
  options: UseIntervalOptions
): void {
  const { delay, enabled = true } = options;

  // Store callback in ref to avoid closure issues
  const savedCallbackRef = useRef<() => void>(callback);

  // Update ref when callback changes
  useEffect(() => {
    savedCallbackRef.current = callback;
  }, [callback]);

  // Set up interval
  useEffect(() => {
    if (!enabled) return;

    if (typeof delay !== 'number' || delay < 0) {
      console.warn(`useInterval: Invalid delay: ${delay}`);
      return;
    }

    const intervalId = setInterval(() => {
      // Always call the latest callback
      savedCallbackRef.current();
    }, delay);

    // Cleanup
    return () => {
      clearInterval(intervalId);
    };
  }, [delay, enabled]);
}

# JavaScript Version

javascript
export function useInterval(callback, options) {
  const { delay, enabled = true } = options;

  const savedCallbackRef = useRef(callback);

  useEffect(() => {
    savedCallbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    if (!enabled) return;

    if (typeof delay !== 'number' || delay < 0) {
      console.warn(`useInterval: Invalid delay: ${delay}`);
      return;
    }

    const intervalId = setInterval(() => {
      savedCallbackRef.current();
    }, delay);

    return () => {
      clearInterval(intervalId);
    };
  }, [delay, enabled]);
}

# How It Works

The key insight is the ref pattern:

  1. Callback Reference: Store the callback in a ref so the interval always calls the most recent version
  2. Update Ref: Update the ref whenever the callback changes (via the first useEffect)
  3. Stable Interval: The actual interval only depends on delay and enabled, not the callback
  4. No Closure Issues: The callback can safely access current state/props through the ref

Usage looks like this:

typescript
function CountingTimer() {
  const [count, setCount] = useState(0);

  // This callback is defined fresh each render, but the ref keeps it current
  useInterval(
    () => {
      // ✅ This safely accesses current count
      console.log('Current count:', count);
      setCount(c => c + 1); // ✅ Use updater function for independent state
    },
    { delay: 1000, enabled: true }
  );

  return <div>Count: {count}</div>;
}

# Advanced Patterns: Delays and Pausing

# Conditional Intervals with Pause

typescript
interface UseIntervalAdvancedOptions {
  delay: number;
  enabled?: boolean;
  // Fire immediately on mount, then at interval
  fireOnMount?: boolean;
  // Called when interval is paused
  onPause?: () => void;
  // Called when interval resumes
  onResume?: () => void;
}

export function useIntervalAdvanced(
  callback: () => void,
  options: UseIntervalAdvancedOptions
) {
  const {
    delay,
    enabled = true,
    fireOnMount = false,
    onPause,
    onResume,
  } = options;

  const savedCallbackRef = useRef<() => void>(callback);
  const previousEnabledRef = useRef(enabled);

  useEffect(() => {
    savedCallbackRef.current = callback;
  }, [callback]);

  // Fire immediately if requested
  useEffect(() => {
    if (enabled && fireOnMount) {
      savedCallbackRef.current();
    }
  }, [enabled, fireOnMount]);

  // Detect pause/resume transitions
  useEffect(() => {
    if (previousEnabledRef.current !== enabled) {
      if (!enabled) {
        onPause?.();
      } else {
        onResume?.();
      }
      previousEnabledRef.current = enabled;
    }
  }, [enabled, onPause, onResume]);

  useEffect(() => {
    if (!enabled) return;

    const intervalId = setInterval(() => {
      savedCallbackRef.current();
    }, delay);

    return () => clearInterval(intervalId);
  }, [delay, enabled]);
}

# JavaScript Version

javascript
export function useIntervalAdvanced(callback, options) {
  const {
    delay,
    enabled = true,
    fireOnMount = false,
    onPause,
    onResume,
  } = options;

  const savedCallbackRef = useRef(callback);
  const previousEnabledRef = useRef(enabled);

  useEffect(() => {
    savedCallbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    if (enabled && fireOnMount) {
      savedCallbackRef.current();
    }
  }, [enabled, fireOnMount]);

  useEffect(() => {
    if (previousEnabledRef.current !== enabled) {
      if (!enabled) {
        onPause?.();
      } else {
        onResume?.();
      }
      previousEnabledRef.current = enabled;
    }
  }, [enabled, onPause, onResume]);

  useEffect(() => {
    if (!enabled) return;

    const intervalId = setInterval(() => {
      savedCallbackRef.current();
    }, delay);

    return () => clearInterval(intervalId);
  }, [delay, enabled]);
}

# Handling Stale Closures

# The Updater Function Pattern

typescript
function StaleClosureExample() {
  const [count, setCount] = useState(0);
  const [multiplier, setMultiplier] = useState(1);

  // ❌ WRONG: This accesses stale multiplier
  // useInterval(
  //   () => {
  //     setCount(count + multiplier); // multiplier captured at creation
  //   },
  //   { delay: 1000 }
  // );

  // ✅ CORRECT: Use updater function
  useInterval(
    () => {
      setCount(prev => prev + 1);

      // Access multiplier from props/state via a ref if needed
      // (though incrementing by constant is better)
    },
    { delay: 1000 }
  );

  return (
    <div>
      Count: {count}
      <button onClick={() => setMultiplier(m => m + 1)}>
        Multiplier: {multiplier}
      </button>
    </div>
  );
}

# Accessing Current Values via useRef

When you need to access current state/props inside the interval:

typescript
function AccessCurrentState() {
  const [count, setCount] = useState(0);
  const [multiplier, setMultiplier] = useState(1);

  // Keep multiplier in sync with ref
  const multiplierRef = useRef(multiplier);

  useEffect(() => {
    multiplierRef.current = multiplier;
  }, [multiplier]);

  useInterval(
    () => {
      // Now safely access the current multiplier
      setCount(prev => prev + multiplierRef.current);
    },
    { delay: 1000 }
  );

  return (
    <div>
      Count: {count}
      <button onClick={() => setMultiplier(m => m + 1)}>
        Multiplier: {multiplier}
      </button>
    </div>
  );
}

# Practical Application Scenarios

# Scenario 1: Countdown Timer

A classic use case with pause/resume:

typescript
interface CountdownTimerProps {
  initialSeconds: number;
  onComplete?: () => void;
}

export function CountdownTimer({
  initialSeconds,
  onComplete,
}: CountdownTimerProps) {
  const [secondsLeft, setSecondsLeft] = useState(initialSeconds);
  const [isRunning, setIsRunning] = useState(false);

  useInterval(
    () => {
      setSecondsLeft(prev => {
        const next = prev - 1;

        // Trigger callback when timer completes
        if (next <= 0) {
          setIsRunning(false);
          onComplete?.();
          return 0;
        }

        return next;
      });
    },
    { delay: 1000, enabled: isRunning }
  );

  const minutes = Math.floor(secondsLeft / 60);
  const seconds = secondsLeft % 60;

  const handleStart = () => setIsRunning(true);
  const handleStop = () => setIsRunning(false);
  const handleReset = () => {
    setIsRunning(false);
    setSecondsLeft(initialSeconds);
  };

  return (
    <div className="countdown-timer">
      <div className="time-display">
        {String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
      </div>

      <div className="controls">
        <button onClick={handleStart} disabled={isRunning}>
          Start
        </button>
        <button onClick={handleStop} disabled={!isRunning}>
          Pause
        </button>
        <button onClick={handleReset}>Reset</button>
      </div>

      {secondsLeft === 0 && <div className="complete">Time's up!</div>}
    </div>
  );
}

# Scenario 2: Polling API with Backoff

Regularly fetch data, but back off if the server is slow:

typescript
interface PollingOptions {
  url: string;
  initialDelayMs?: number;
  maxDelayMs?: number;
  backoffMultiplier?: number;
}

export function usePollingData(options: PollingOptions) {
  const {
    url,
    initialDelayMs = 5000,
    maxDelayMs = 60000,
    backoffMultiplier = 1.5,
  } = options;

  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [currentDelayMs, setCurrentDelayMs] = useState(initialDelayMs);

  const fetchData = useCallback(async () => {
    setIsLoading(true);

    try {
      const response = await fetch(url);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const result = await response.json();
      setData(result);
      setError(null);

      // Reset delay on success
      setCurrentDelayMs(initialDelayMs);
    } catch (err) {
      const error = err instanceof Error ? err : new Error('Unknown error');
      setError(error);

      // Increase delay on failure (back off)
      setCurrentDelayMs(prev =>
        Math.min(prev * backoffMultiplier, maxDelayMs)
      );
    } finally {
      setIsLoading(false);
    }
  }, [url, initialDelayMs, maxDelayMs, backoffMultiplier]);

  // Fetch immediately on mount
  useEffect(() => {
    fetchData();
  }, [fetchData]);

  // Poll at current interval
  useInterval(() => {
    fetchData();
  }, { delay: currentDelayMs, enabled: true });

  return { data, isLoading, error };
}

// Usage
function StatusMonitor() {
  const { data, isLoading, error } = usePollingData({
    url: '/api/system-status',
    initialDelayMs: 5000,
  });

  return (
    <div>
      {isLoading && <div>Checking status...</div>}
      {error && <div>Error: {error.message}</div>}
      {data && <div>Status: {data.status}</div>}
    </div>
  );
}

# Scenario 3: Stopwatch/Elapsed Time Tracker

Count upward with formatting:

typescript
export function Stopwatch() {
  const [elapsedMs, setElapsedMs] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useInterval(
    () => {
      setElapsedMs(prev => prev + 100); // Update every 100ms for smoother display
    },
    { delay: 100, enabled: isRunning }
  );

  const formatTime = (ms: number): string => {
    const totalSeconds = Math.floor(ms / 1000);
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    const centiseconds = Math.floor((ms % 1000) / 10);

    return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`;
  };

  return (
    <div className="stopwatch">
      <div className="display">{formatTime(elapsedMs)}</div>

      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Pause' : 'Start'}
      </button>

      <button onClick={() => setElapsedMs(0)}>Reset</button>
    </div>
  );
}

# Scenario 4: Heartbeat/Keep-Alive Signal

Periodically notify a server that the connection is alive:

typescript
export function useHeartbeat(
  sessionId: string,
  intervalMs: number = 30000
) {
  const sessionIdRef = useRef(sessionId);

  useEffect(() => {
    sessionIdRef.current = sessionId;
  }, [sessionId]);

  const sendHeartbeat = useCallback(async () => {
    try {
      await fetch('/api/heartbeat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId: sessionIdRef.current }),
      });
    } catch (error) {
      console.error('Heartbeat failed:', error);
    }
  }, []);

  useInterval(() => {
    sendHeartbeat();
  }, { delay: intervalMs, enabled: true });
}

// Usage in a video call component
function VideoCallRoom({ sessionId }: { sessionId: string }) {
  useHeartbeat(sessionId, 30000); // Send heartbeat every 30 seconds

  return (
    <div className="video-room">
      {/* Video call UI */}
    </div>
  );
}

# Scenario 5: Background Sync with Exponential Backoff

Retry failed operations with increasing delays:

typescript
interface BackgroundSyncTask {
  id: string;
  execute: () => Promise<void>;
}

export function useBackgroundSync(
  task: BackgroundSyncTask,
  maxRetries: number = 5
) {
  const [retryCount, setRetryCount] = useState(0);
  const [isComplete, setIsComplete] = useState(false);
  const [lastError, setLastError] = useState<Error | null>(null);

  // Exponential backoff: 1s, 2s, 4s, 8s, 16s
  const delayMs = Math.min(1000 * Math.pow(2, retryCount), 30000);

  const attemptSync = useCallback(async () => {
    try {
      await task.execute();
      setIsComplete(true);
      setLastError(null);
    } catch (error) {
      const err = error instanceof Error ? error : new Error('Unknown error');
      setLastError(err);

      if (retryCount < maxRetries) {
        setRetryCount(prev => prev + 1);
      } else {
        // Max retries exceeded, give up
        console.error(`Sync failed after ${maxRetries} attempts:`, err);
      }
    }
  }, [task, retryCount, maxRetries]);

  useInterval(() => {
    attemptSync();
  }, { delay: delayMs, enabled: !isComplete && retryCount < maxRetries });

  return { isComplete, retryCount, lastError };
}

# Performance and Cleanup

# Preventing Memory Leaks

The hook's cleanup function is critical:

typescript
// ✅ CORRECT: Cleanup prevents interval pile-up
useEffect(() => {
  const intervalId = setInterval(callback, delay);

  return () => {
    clearInterval(intervalId); // Always clean up!
  };
}, [delay]);

// Testing that cleanup works
function TestCleanup() {
  const [mounted, setMounted] = useState(true);

  // Every toggle should cleanly create/destroy the interval
  return (
    <>
      {mounted && <Timer />}
      <button onClick={() => setMounted(!mounted)}>
        {mounted ? 'Unmount' : 'Mount'} Timer
      </button>
    </>
  );
}

# Batch Multiple Intervals

When you have multiple intervals, consider batching to reduce function call overhead:

typescript
export function useMultipleIntervals(
  callbacks: Array<{ callback: () => void; delay: number }>
) {
  const callbacksRef = useRef(callbacks);

  useEffect(() => {
    callbacksRef.current = callbacks;
  }, [callbacks]);

  useEffect(() => {
    const intervalIds = callbacks.map(({ delay }) => {
      return setInterval(() => {
        // Fire all callbacks with this delay
        callbacksRef.current
          .filter(cb => cb.delay === delay)
          .forEach(cb => cb.callback());
      }, delay);
    });

    return () => {
      intervalIds.forEach(id => clearInterval(id));
    };
  }, [callbacks]);
}

# Disabling Intervals During Low Battery

Respect user's power-saving preferences:

typescript
export function useRespectfulInterval(
  callback: () => void,
  delay: number
) {
  const [isLowBattery, setIsLowBattery] = useState(false);

  // Listen for battery status
  useEffect(() => {
    if (!('getBattery' in navigator)) return;

    (navigator as any).getBattery().then((battery: any) => {
      const updateBattery = () => {
        setIsLowBattery(battery.level < 0.2 && !battery.charging);
      };

      battery.addEventListener('chargingchange', updateBattery);
      battery.addEventListener('levelchange', updateBattery);

      return () => {
        battery.removeEventListener('chargingchange', updateBattery);
        battery.removeEventListener('levelchange', updateBattery);
      };
    });
  }, []);

  // Disable interval during low battery
  useInterval(callback, { delay, enabled: !isLowBattery });
}

# FAQ

# Q: Why do I need a ref for the callback?

A: Without it, the interval would be recreated every time the callback changes, destroying and recreating the timer unnecessarily. The ref lets you update the callback without recreating the interval itself.

# Q: Can I use multiple useInterval hooks in the same component?

A: Yes, each hook maintains its own interval:

typescript
function MultipleTimers() {
  useInterval(() => console.log('Timer 1'), { delay: 1000 });
  useInterval(() => console.log('Timer 2'), { delay: 2000 });
  useInterval(() => console.log('Timer 3'), { delay: 5000 });
}

# Q: How do I cancel an interval from inside the callback?

A: Use a ref to pause the interval:

typescript
function AutoCancelllingTimer() {
  const [enabled, setEnabled] = useState(true);
  const countRef = useRef(0);

  useInterval(
    () => {
      countRef.current++;

      if (countRef.current >= 10) {
        setEnabled(false); // Stop after 10 ticks
      }
    },
    { delay: 1000, enabled }
  );

  return <div>Count: {countRef.current}</div>;
}

# Q: What if I need to update the delay dynamically?

A: Just pass the new delay value. The interval will restart with the new timing:

typescript
function DynamicInterval() {
  const [delayMs, setDelayMs] = useState(1000);

  useInterval(
    () => console.log('Tick'),
    { delay: delayMs, enabled: true }
  );

  return (
    <div>
      <button onClick={() => setDelayMs(500)}>Faster</button>
      <button onClick={() => setDelayMs(2000)}>Slower</button>
    </div>
  );
}

# Q: How do I test components using useInterval?

A: Mock or use jest.useFakeTimers():

typescript
test('countdown decrements every second', () => {
  jest.useFakeTimers();

  render(<CountdownTimer initialSeconds={10} />);

  expect(screen.getByText('00:10')).toBeInTheDocument();

  act(() => {
    jest.advanceTimersByTime(1000);
  });

  expect(screen.getByText('00:09')).toBeInTheDocument();

  jest.useRealTimers();
});

# Q: Can I use useInterval with SSR (Next.js)?

A: Yes, but intervals only run in the browser:

typescript
// Safe for SSR - interval never fires on server
export function Timer() {
  const [count, setCount] = useState(0);

  useInterval(
    () => setCount(c => c + 1),
    { delay: 1000 } // Only runs in browser
  );

  return <div>{count}</div>;
}

# Q: How do I stop an interval from a different component?

A: Use a shared context or state management:

typescript
const TimerContext = createContext<{
  isRunning: boolean;
  setIsRunning: (running: boolean) => void;
} | null>(null);

export function TimerProvider({ children }: { children: ReactNode }) {
  const [isRunning, setIsRunning] = useState(false);

  return (
    <TimerContext.Provider value={{ isRunning, setIsRunning }}>
      {children}
    </TimerContext.Provider>
  );
}

// In component
function Timer() {
  const timerContext = useContext(TimerContext);

  useInterval(
    () => console.log('Tick'),
    { delay: 1000, enabled: timerContext?.isRunning ?? false }
  );
}

# Common Patterns

Pattern 1: Double Interval (fine and coarse updates)

typescript
function ClockDisplay() {
  const [seconds, setSeconds] = useState(0);
  const [milliseconds, setMilliseconds] = useState(0);

  // Coarse update every 1 second
  useInterval(
    () => setSeconds(s => s + 1),
    { delay: 1000, enabled: true }
  );

  // Fine update every 10ms for smooth display
  useInterval(
    () => setMilliseconds(ms => (ms + 10) % 1000),
    { delay: 10, enabled: true }
  );

  return <div>{seconds}.{String(milliseconds).padStart(3, '0')}</div>;
}

Pattern 2: Interval that adapts based on component visibility

typescript
export function useVisibilityAwareInterval(
  callback: () => void,
  delay: number
) {
  const [isVisible, setIsVisible] = useState(true);

  useEffect(() => {
    const handleVisibilityChange = () => {
      setIsVisible(!document.hidden);
    };

    document.addEventListener('visibilitychange', handleVisibilityChange);

    return () => {
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    };
  }, []);

  // Pause when tab is hidden
  useInterval(callback, { delay, enabled: isVisible });
}


# Next Steps

The useInterval hook becomes more powerful when combined with:

  • Context for sharing timer state across components
  • Frameworks like Redux for complex timer coordination
  • Battery and visibility APIs for respectful resource usage
  • Animations tied to intervals for visual feedback

Start with simple countdown timers and stopwatches. As your application scales to real-time features (ByteDance's live streams, Alibaba's auction countdowns), timer management becomes infrastructure.

What timing problems have you solved with intervals? Share your implementations in the comments—heartbeat logic, polling strategies, and visual sync challenges always spark interesting discussions among teams building at scale.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT