Fiber UI LogoFiberUI

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.json

A 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 null as 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


Live Counter

A simple counter that increments every second. Toggle between play and pause, or reset to zero.

000

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.

API Polling (Paused)
No logs yet. Start polling to begin.

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.

Ready to upload0%

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

NameTypeDescription
callback() => voidThe function to execute on every tick.
delaynumber | nullInterval 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;