Fiber UI LogoFiberUI

useTimeout

A React hook for managing setTimeout with automatic cleanup and manual controls. Supports resettable timers, delayed actions, and null-delay pausing — all stale-closure safe.

Installation

npx shadcn@latest add https://r.fiberui.com/r/hooks/use-timeout.json

A declarative wrapper around setTimeout that provides clear and reset controls. The timeout is automatically cleaned up on unmount, and the callback always has access to the freshest state (no stale closures).

Source Code

View the full hook implementation in the Hook Source Code section below.

Features

  • Auto-Cleanup — Clears the timeout when the component unmounts
  • Resettable — Call reset() to restart the timer from scratch
  • Pausable — Pass null as the delay to prevent execution
  • Stale Closure Safe — The callback ref is updated every render, so it always sees the latest state
  • Zero Dependencies — Uses only React's built-in hooks

Learn More


Auto-Hide Notification

A notification that automatically dismisses itself after 3 seconds. Click the reset button to keep it visible, or dismiss it manually.

Click the button to show a self-dismissing notification

"use client";

import { useTimeout } from "@repo/hooks/utility/use-timeout";
import { Button } from "@repo/ui/components/button";
import { useState } from "react";
import { Bell, X, RotateCcw } from "lucide-react";

export const Example1 = () => {
    const [visible, setVisible] = useState(false);

    // Auto-hide the notification after 3 seconds
    const { reset, clear } = useTimeout(
        () => {
            setVisible(false);
        },
        visible ? 3000 : null,
    );

    const show = () => {
        setVisible(true);
        reset();
    };

    const dismiss = () => {
        clear();
        setVisible(false);
    };

    return (
        <div className="flex flex-col items-center gap-4 p-6">
            <Button variant="outline" onPress={show}>
                <Bell className="mr-2 h-4 w-4" />
                Show Notification
            </Button>

            {visible && (
                <div className="flex w-full max-w-sm items-center gap-3 rounded-lg border bg-zinc-950 p-4 text-white shadow-lg">
                    <Bell className="h-5 w-5 shrink-0 text-blue-400" />
                    <div className="flex-1">
                        <p className="text-sm font-medium">New message!</p>
                        <p className="text-xs text-zinc-400">
                            This will auto-hide in 3 seconds.
                        </p>
                    </div>
                    <div className="flex gap-1">
                        <Button
                            variant="ghost"
                            size="icon"
                            className="h-7 w-7 text-zinc-400 hover:text-white"
                            onPress={() => reset()}
                            aria-label="Reset timer"
                        >
                            <RotateCcw className="h-3.5 w-3.5" />
                        </Button>
                        <Button
                            variant="ghost"
                            size="icon"
                            className="h-7 w-7 text-zinc-400 hover:text-white"
                            onPress={dismiss}
                            aria-label="Dismiss"
                        >
                            <X className="h-3.5 w-3.5" />
                        </Button>
                    </div>
                </div>
            )}

            <p className="text-muted-foreground text-xs">
                {visible
                    ? "Notification visible — resets on click, auto-hides after 3s"
                    : "Click the button to show a self-dismissing notification"}
            </p>
        </div>
    );
};

Uses useTimeout to delay the search until the user stops typing for 500ms. Each keystroke resets the timer, effectively debouncing the input.

How it works

Every keystroke calls reset(), which clears the previous timeout and starts a new one. The search only fires when the user pauses for 500ms.

Search is debounced — waits 500ms after you stop typing.

"use client";

import { useTimeout } from "@repo/hooks/utility/use-timeout";
import { useState, useCallback } from "react";
import { Search, Loader2 } from "lucide-react";

const MOCK_RESULTS = [
    "React Hooks Guide",
    "React Server Components",
    "React Performance Tips",
    "React Testing Library",
    "React Design Patterns",
];

export const Example2 = () => {
    const [query, setQuery] = useState("");
    const [results, setResults] = useState<string[]>([]);
    const [isSearching, setIsSearching] = useState(false);

    // Debounce the search — only fire 500ms after the user stops typing
    const { reset } = useTimeout(
        () => {
            if (query.trim()) {
                setIsSearching(true);

                // Simulate API delay
                setTimeout(() => {
                    setResults(
                        MOCK_RESULTS.filter((r) =>
                            r.toLowerCase().includes(query.toLowerCase()),
                        ),
                    );
                    setIsSearching(false);
                }, 300);
            } else {
                setResults([]);
            }
        },
        query ? 500 : null,
    );

    const handleChange = useCallback(
        (e: React.ChangeEvent<HTMLInputElement>) => {
            setQuery(e.target.value);
            setIsSearching(false);
            setResults([]);
            reset();
        },
        [reset],
    );

    return (
        <div className="mx-auto w-full max-w-sm space-y-3 p-6">
            <div className="relative">
                <Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
                <input
                    type="text"
                    value={query}
                    onChange={handleChange}
                    placeholder="Search articles..."
                    className="bg-background h-10 w-full rounded-lg border pl-9 pr-4 text-sm outline-none focus:ring-2 focus:ring-blue-500"
                />
                {isSearching && (
                    <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-blue-500" />
                )}
            </div>

            {results.length > 0 && (
                <ul className="space-y-1 rounded-lg border p-2">
                    {results.map((result) => (
                        <li
                            key={result}
                            className="text-muted-foreground hover:bg-muted cursor-pointer rounded-md px-3 py-2 text-sm transition-colors"
                        >
                            {result}
                        </li>
                    ))}
                </ul>
            )}

            {query && !isSearching && results.length === 0 && (
                <p className="text-muted-foreground text-center text-xs">
                    No results found for &quot;{query}&quot;
                </p>
            )}

            <p className="text-muted-foreground text-center text-xs">
                Search is debounced — waits 500ms after you stop typing.
            </p>
        </div>
    );
};

Idle Timeout Warning

Simulates a session timeout: after 5 seconds of inactivity a warning appears, and after 3 more seconds the session expires. Any interaction resets the timer chain.

Session Active

Your session is active. Stop interacting to trigger the idle warning.

Idle → 5s → Warning → 3s → Expired

"use client";

import { useTimeout } from "@repo/hooks/utility/use-timeout";
import { Button } from "@repo/ui/components/button";
import { useState, useCallback } from "react";
import { ShieldAlert, MousePointerClick } from "lucide-react";

export const Example3 = () => {
    const [status, setStatus] = useState<"active" | "warning" | "expired">(
        "active",
    );

    // After 5 seconds of inactivity, show warning
    const { reset: resetWarning } = useTimeout(
        () => {
            setStatus("warning");
        },
        status === "active" ? 5000 : null,
    );

    // After 8 seconds total (3s after warning), expire
    const { reset: resetExpiry } = useTimeout(
        () => {
            setStatus("expired");
        },
        status === "warning" ? 3000 : null,
    );

    const handleActivity = useCallback(() => {
        setStatus("active");
        resetWarning();
        resetExpiry();
    }, [resetWarning, resetExpiry]);

    const statusConfig = {
        active: {
            color: "bg-green-500",
            border: "border-green-500/20",
            label: "Session Active",
            description:
                "Your session is active. Stop interacting to trigger the idle warning.",
        },
        warning: {
            color: "bg-yellow-500",
            border: "border-yellow-500/20",
            label: "Idle Warning",
            description:
                "You've been idle for 5 seconds. Session expires in 3 seconds...",
        },
        expired: {
            color: "bg-red-500",
            border: "border-red-500/20",
            label: "Session Expired",
            description: "Your session has timed out due to inactivity.",
        },
    };

    const config = statusConfig[status];

    return (
        <div className="flex flex-col items-center gap-4 p-6">
            <div
                className={`w-full max-w-sm rounded-lg border ${config.border} p-6 transition-all duration-300`}
            >
                <div className="mb-4 flex items-center gap-3">
                    <ShieldAlert className="h-5 w-5" />
                    <div className="flex items-center gap-2">
                        <span
                            className={`h-2.5 w-2.5 rounded-full ${config.color} ${status === "warning" ? "animate-pulse" : ""}`}
                        />
                        <span className="text-sm font-semibold">
                            {config.label}
                        </span>
                    </div>
                </div>

                <p className="text-muted-foreground mb-4 text-sm">
                    {config.description}
                </p>

                <Button
                    variant="outline"
                    className="w-full"
                    onPress={handleActivity}
                >
                    <MousePointerClick className="mr-2 h-4 w-4" />
                    {status === "expired"
                        ? "Restart Session"
                        : "I'm still here!"}
                </Button>
            </div>

            <p className="text-muted-foreground text-xs">
                Idle → 5s → Warning → 3s → Expired
            </p>
        </div>
    );
};

Common Patterns

Delayed Action (one-shot)

const { clear } = useTimeout(() => {
    setShowModal(false);
}, 5000);

Conditional Execution

// Only schedule when isReady is true
const { reset } = useTimeout(() => doSomething(), isReady ? 2000 : null);

Reset on Interaction

const { reset } = useTimeout(() => hideTooltip(), 3000);

return <div onMouseMove={reset}>Hover content</div>;

API Reference

Hook Signature

function useTimeout(
    callback: () => void,
    delay: number | null,
): UseTimeoutReturn;

Parameters

NameTypeDescription
callback() => voidThe function to execute after the delay.
delaynumber | nullDelay in milliseconds. Pass null to prevent execution.

UseTimeoutReturn

NameTypeDescription
clear() => voidClears the active timeout. Safe to call multiple times.
reset() => voidClears the current timeout and starts a new one immediately.

Delay of 0

A delay of 0 is valid and pushes the callback to the next event-loop tick. Only null prevents execution entirely.


Hook Source Code

import { useEffect, useRef, useCallback } from "react";

/**
 * Return type for useTimeout hook.
 */
export interface UseTimeoutReturn {
    /**
     * Clears the active timeout safely.
     */
    clear: () => void;
    /**
     * Resets the timeout (clears existing and starts a new one).
     */
    reset: () => void;
}

/**
 * A React hook for handling `setTimeout` with manual controls.
 *
 * - **Auto-Cleanup:** Clears timeout on unmount.
 * - **Resettable:** Great for delaying actions (like hiding a tooltip).
 * - **Stale Closure Safe:** Callback always has access to fresh state.
 *
 * @param callback - The function to execute after the delay.
 * @param delay - The delay in milliseconds. Pass `null` to prevent execution.
 * @returns An object containing `clear` and `reset` functions.
 *
 * @example
 * ```tsx
 * const { reset, clear } = useTimeout(() => {
 * setShowModal(false);
 * }, 5000);
 *
 * // Reset the timer if the user moves their mouse
 * <div onMouseMove={reset}>
 * Don't hide me yet!
 * </div>
 * ```
 */
export function useTimeout(
    callback: () => void,
    delay: number | null,
): UseTimeoutReturn {
    const savedCallback = useRef(callback);
    const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);

    // 1. Remember the latest callback.
    useEffect(() => {
        savedCallback.current = callback;
    }, [callback]);

    // 2. Define control functions (memoized to be stable dependencies)

    const clear = useCallback(() => {
        if (timeoutId.current) {
            clearTimeout(timeoutId.current);
            timeoutId.current = null;
        }
    }, []);

    const set = useCallback(() => {
        if (delay === null) return;

        timeoutId.current = setTimeout(() => {
            savedCallback.current();
        }, delay);
    }, [delay]);

    const reset = useCallback(() => {
        clear();
        set();
    }, [clear, set]);

    // 3. Set up the timeout on mount or delay change
    useEffect(() => {
        set();
        return clear;
    }, [delay, set, clear]);

    return { clear, reset };
}

export default useTimeout;