useInterval
A declarative React hook for setInterval that is stale-closure safe, pausable via null delay, and automatically cleaned up on unmount. Perfect for polling, counters, and animations.
Installation
npx shadcn@latest add https://r.fiberui.com/r/hooks/use-interval.jsonA declarative wrapper around setInterval. The interval is automatically cleaned up when the component unmounts or when the delay changes, and the callback always sees the latest state — no stale closures.
Source Code
View the full hook implementation in the Hook Source Code section below.
Features
- Stale Closure Safe — Always executes the latest callback without restarting the timer
- Pausable — Pass
nullas the delay to pause the interval - Auto-Cleanup — Clears the interval on unmount or when the delay changes
- Zero Dependencies — Uses only React's built-in hooks
Learn More
MDN: setInterval()
The Web API that powers this hook
Making setInterval Declarative
Dan Abramov's deep-dive on the stale closure problem
Live Counter
A simple counter that increments every second. Toggle between play and pause, or reset to zero.
Paused
"use client";
import { useInterval } from "@repo/hooks/utility/use-interval";
import { Button } from "@repo/ui/components/button";
import { useState } from "react";
import { Play, Pause, RotateCcw } from "lucide-react";
export const Example1 = () => {
const [count, setCount] = useState(0);
const [isRunning, setIsRunning] = useState(false);
// Increment every second when running, pass null to pause
useInterval(
() => {
setCount((c) => c + 1);
},
isRunning ? 1000 : null,
);
return (
<div className="flex flex-col items-center gap-4 rounded-lg border p-6">
<div className="font-mono text-6xl font-bold tabular-nums">
{count.toString().padStart(3, "0")}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onPress={() => setIsRunning((r) => !r)}
aria-label={isRunning ? "Pause" : "Start"}
>
{isRunning ? (
<Pause className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
onPress={() => {
setIsRunning(false);
setCount(0);
}}
aria-label="Reset"
>
<RotateCcw className="h-4 w-4" />
</Button>
</div>
<p className="text-muted-foreground text-xs">
{isRunning ? "Counting… (1 tick/second)" : "Paused"}
</p>
</div>
);
};
Pausable Polling
Simulates an API endpoint being polled every 2 seconds. Toggle the interval on and off to start and stop polling. Uses null delay to pause.
How it works
The delay is conditionally set: isPolling ? 2000 : null. When null is
passed, the interval is not scheduled at all — no CPU wasted.
Polls every 2 seconds. Pass null as the delay to pause.
"use client";
import { useInterval } from "@repo/hooks/utility/use-interval";
import { Button } from "@repo/ui/components/button";
import { useState } from "react";
import { Wifi, WifiOff, Trash2 } from "lucide-react";
export const Example2 = () => {
const [isPolling, setIsPolling] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
// Poll every 2 seconds when active, null to pause
useInterval(
() => {
const now = new Date().toLocaleTimeString();
setLogs((prev) =>
[`[${now}] Polled — 200 OK`, ...prev].slice(0, 8),
);
},
isPolling ? 2000 : null,
);
return (
<div className="mx-auto w-full max-w-sm space-y-4 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isPolling ? (
<Wifi className="h-4 w-4 text-green-500" />
) : (
<WifiOff className="h-4 w-4 text-zinc-500" />
)}
<span className="text-sm font-medium">
API Polling {isPolling ? "(Active)" : "(Paused)"}
</span>
</div>
<div className="flex gap-2">
<Button
variant={isPolling ? "destructive" : "default"}
size="sm"
onPress={() => setIsPolling((p) => !p)}
>
{isPolling ? "Stop" : "Start"} Polling
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onPress={() => setLogs([])}
aria-label="Clear logs"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="h-48 overflow-y-auto rounded-lg border bg-zinc-950 p-3 font-mono text-xs text-green-400">
{logs.length === 0 ? (
<span className="text-zinc-600">
No logs yet. Start polling to begin.
</span>
) : (
logs.map((log, i) => (
<div
key={i}
className="border-b border-zinc-800 py-1 last:border-0"
>
{log}
</div>
))
)}
</div>
<p className="text-muted-foreground text-center text-xs">
Polls every 2 seconds. Pass <code>null</code> as the delay to
pause.
</p>
</div>
);
};
Progress Bar
An animated progress bar that fills by a random amount every 150ms. The interval stops itself once 100% is reached by flipping to a null delay.
Progress ticks every 150ms. Interval stops automatically at 100%.
"use client";
import { useInterval } from "@repo/hooks/utility/use-interval";
import { Button } from "@repo/ui/components/button";
import { useState } from "react";
import { RotateCcw } from "lucide-react";
export const Example3 = () => {
const [progress, setProgress] = useState(0);
const [isRunning, setIsRunning] = useState(false);
// Increment progress by a random amount every 150ms
useInterval(
() => {
setProgress((prev) => {
const next = prev + Math.random() * 4 + 1;
if (next >= 100) {
setIsRunning(false);
return 100;
}
return next;
});
},
isRunning ? 150 : null,
);
const handleStart = () => {
setProgress(0);
setIsRunning(true);
};
const isComplete = progress >= 100;
return (
<div className="mx-auto w-full max-w-sm space-y-4 p-6">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{isComplete
? "Upload Complete!"
: isRunning
? "Uploading..."
: "Ready to upload"}
</span>
<span className="font-mono text-xs tabular-nums">
{Math.round(progress)}%
</span>
</div>
{/* Progress bar */}
<div className="bg-muted h-3 w-full overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-150 ${
isComplete
? "bg-green-500"
: "bg-linear-to-r from-blue-500 to-violet-500"
}`}
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
<div className="flex gap-2">
<Button
className="flex-1"
onPress={handleStart}
isDisabled={isRunning}
>
{isComplete ? (
<>
<RotateCcw className="mr-2 h-4 w-4" />
Upload Again
</>
) : (
"Start Upload"
)}
</Button>
</div>
<p className="text-muted-foreground text-center text-xs">
Progress ticks every 150ms. Interval stops automatically at
100%.
</p>
</div>
);
};
Common Patterns
Basic Tick
useInterval(() => {
setCount((c) => c + 1);
}, 1000);Pausable
// Pass null to pause the interval entirely
useInterval(() => fetchData(), isPaused ? null : 3000);Dynamic Speed
// Change the delay to speed up or slow down
const [speed, setSpeed] = useState(1000);
useInterval(() => tick(), speed);API Reference
Hook Signature
function useInterval(callback: () => void, delay: number | null): void;Parameters
| Name | Type | Description |
|---|---|---|
callback | () => void | The function to execute on every tick. |
delay | number | null | Interval in milliseconds. Pass null to pause. |
Returns
void — This hook does not return anything. Control the interval by changing the delay value (set to null to pause, set to a number to start/resume).
Delay of 0
A delay of 0 is valid — it schedules the callback for the next event-loop
tick. Only null prevents the interval from being created.
Hook Source Code
import { useEffect, useRef } from "react";
/**
* A declarative React hook for `setInterval`.
*
* - **Stale Closure Safe:** Always executes the latest version of the callback without restarting the timer.
* - **Pausable:** Pass `null` as the delay to pause the interval.
* - **Auto-Cleanup:** Clears the interval when the component unmounts or delay changes.
*
* @param callback - The function to execute on every tick.
* @param delay - The delay in milliseconds. Pass `null` to pause.
*
* @example
* ```tsx
* // Basic usage
* useInterval(() => {
* setCount(count + 1);
* }, 1000);
*
* // Pausable usage
* useInterval(() => {
* console.log("Polling...");
* }, isPaused ? null : 3000);
* ```
*/
export function useInterval(
callback: () => void,
delay: number | null
): void {
// 1. Keep a reference to the latest callback.
// This avoids the "stale closure" problem where the interval
// executes an old version of the function that sees old state.
const savedCallback = useRef(callback);
// Update the ref each render so the interval always sees the latest state
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// 2. Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
// Note: 0 is a valid value for delay (it pushes to the next event loop tick).
if (delay === null) {
return;
}
const handler = () => {
savedCallback.current();
};
const id = setInterval(handler, delay);
// Cleanup: Clear interval when component unmounts or delay changes
return () => clearInterval(id);
}, [delay]);
}
export default useInterval;useEyeDropper
A specialized utility hook for selecting colors from anywhere on the screen using the modern EyeDropper API, providing hex color codes effortlessly.
useIsMounted
A utility hook to solve hydration mismatch issues. Returns true only after the component has mounted on the client, ensuring safe access to browser APIs.