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
- Why setInterval is Tricky in React
- Basic useInterval Implementation
- Advanced Patterns: Delays and Pausing
- Handling Stale Closures
- Practical Application Scenarios
- Performance and Cleanup
- FAQ
Why setInterval is Tricky in React
The intuitive approach fails immediately:
// ❌ 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:
- Stale Closure: The callback captures
countfrom the initial render. It always seescount = 0, even after state updates. - Jumpy Increments: Each interval sets
countbased on the stale value, then the next render causes another re-render before the next interval fires. - Memory Leak: No cleanup function, so intervals pile up with every component remount.
Adding dependencies makes it worse:
// ❌ 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
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
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:
- Callback Reference: Store the callback in a ref so the interval always calls the most recent version
- Update Ref: Update the ref whenever the callback changes (via the first
useEffect) - Stable Interval: The actual interval only depends on
delayandenabled, not the callback - No Closure Issues: The callback can safely access current state/props through the ref
Usage looks like this:
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
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
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
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:
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:
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:
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:
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:
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:
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:
// ✅ 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:
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:
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:
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:
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:
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():
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:
// 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:
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)
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
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 });
}
Related Articles
- useEffect Hook: Managing Side Effects
- useCallback Hook: Memoizing Functions
- Polling Patterns in React
- Cleanup Strategies in Hooks
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.
Google AdSense Placeholder
CONTENT SLOT