useDebouncedState
A hook for debouncing state values
A React hook that debounces a value. The debounced value will only update after the specified delay has passed without the value changing. Perfect for search inputs, form validation, and any scenario where you want to reduce update frequency.
Source Code
View the full hook implementation in the Hook Source Code section below.
Related Hook
Need to debounce a function instead of a value? See useDebouncedCallback.
Features
- Value Debouncing - Creates a debounced version of any value
- Pending State -
isPendingtells you when a debounce is waiting to fire - Cancel & Flush -
cancel()discards pending updates,flush()applies them immediately - Leading Edge - Optional immediate first update, then debounce subsequent changes
- SSR Safe - No issues with server-side rendering
Basic Usage
Debounce an input value to reduce updates. Notice how the debounced value only changes after you stop typing:
Immediate Value
empty
Debounced Value (500ms)
empty
The debounced value updates 500ms after you stop typing
"use client";
import { useState } from "react";
import { useDebouncedState } from "@repo/hooks/utility/use-debounced-state";
import { Search, Loader2 } from "lucide-react";
/* BASIC USAGE - Debounced Search Input */
export const Example1 = () => {
const [inputValue, setInputValue] = useState("");
const { debouncedValue, isPending } = useDebouncedState(inputValue, {
delay: 500,
});
return (
<div className="flex flex-col gap-4">
<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={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Type to search..."
className="border-input bg-background w-full rounded-md border py-2 pl-10 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{isPending && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-blue-500" />
)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-muted/50 rounded-md p-3">
<p className="text-muted-foreground mb-1 text-xs font-medium">
Immediate Value
</p>
<p className="wrap-break-word font-mono">
{inputValue || (
<span className="text-muted-foreground italic">
empty
</span>
)}
</p>
</div>
<div className="rounded-md border border-blue-500/50 bg-blue-500/10 p-3">
<p className="mb-1 text-xs font-medium text-blue-600 dark:text-blue-400">
Debounced Value (500ms)
</p>
<p className="wrap-break-word font-mono">
{debouncedValue || (
<span className="text-muted-foreground italic">
empty
</span>
)}
</p>
</div>
</div>
<p className="text-muted-foreground text-xs">
The debounced value updates 500ms after you stop typing
</p>
</div>
);
};
Cancel and Flush
Use cancel() to discard a pending update or flush() to apply it immediately. This example uses a longer 2-second delay to make the controls easier to test:
Debounced Value History
Cancel discards pending changes. Flush applies them immediately.
"use client";
import { useState, useEffect } from "react";
import { useDebouncedState } from "@repo/hooks/utility/use-debounced-state";
import { X, Ban, Zap } from "lucide-react";
/* CANCEL AND FLUSH - Control Functions */
export const Example2 = () => {
const [inputValue, setInputValue] = useState("");
const { debouncedValue, isPending, cancel, flush } = useDebouncedState(
inputValue,
{ delay: 2000 },
);
const [history, setHistory] = useState<string[]>([]);
// Track when debounced value changes
useEffect(() => {
if (debouncedValue) {
setHistory((prev) => [...prev.slice(-4), debouncedValue]);
}
}, [debouncedValue]);
return (
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Type something (2s delay)..."
className="border-input bg-background max-w-lg flex-1 rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={cancel}
disabled={!isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
title="Cancel pending update"
>
<Ban className="h-4 w-4" />
Cancel
</button>
<button
onClick={flush}
disabled={!isPending}
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50"
title="Immediately apply pending value"
>
<Zap className="h-4 w-4" />
Flush
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">Status:</span>
{isPending ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-500/20 px-2.5 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-500" />
Pending...
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-500/20 px-2.5 py-0.5 text-xs font-medium text-green-600 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Synced
</span>
)}
</div>
<div className="bg-muted/30 rounded-md p-3">
<p className="text-muted-foreground mb-2 text-xs font-medium">
Debounced Value History
</p>
<div className="flex flex-wrap gap-2">
{history.length === 0 ? (
<span className="text-muted-foreground text-sm italic">
No values yet
</span>
) : (
history.map((val, i) => (
<p
key={i}
className="wrap-break-word bg-background rounded border px-2 py-1 text-xs"
>
{val}
</p>
))
)}
{history.length > 0 && (
<button
onClick={() => setHistory([])}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<p className="text-muted-foreground text-xs">
<strong>Cancel</strong> discards pending changes.{" "}
<strong>Flush</strong> applies them immediately.
</p>
</div>
);
};
Leading Edge
By default, debounce uses "trailing edge" - updating after the delay. With leading: true, it updates immediately on the first change, then debounces subsequent changes:
waiting...Updates after pause
waiting...Updates immediately, then debounces
Leading edge fires immediately on first keystroke, then debounces. Trailing edge waits for the pause.
"use client";
import { useState, useEffect, useRef } from "react";
import { useDebouncedState } from "@repo/hooks/utility/use-debounced-state";
/* LEADING EDGE - Immediate First Update */
export const Example3 = () => {
const [value, setValue] = useState("");
const { debouncedValue: trailingValue } = useDebouncedState(value, {
delay: 500,
leading: false,
});
const { debouncedValue: leadingValue } = useDebouncedState(value, {
delay: 500,
leading: true,
});
const [trailingUpdates, setTrailingUpdates] = useState(0);
const [leadingUpdates, setLeadingUpdates] = useState(0);
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setTrailingUpdates((c) => c + 1);
}, [trailingValue]);
useEffect(() => {
if (isFirstRender.current) return;
setLeadingUpdates((c) => c + 1);
}, [leadingValue]);
return (
<div className="flex flex-col gap-4">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Type rapidly..."
className="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium">
Trailing Edge (default)
</span>
<span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-xs text-blue-600 dark:text-blue-400">
{trailingUpdates} updates
</span>
</div>
<code className="text-sm">
{trailingValue || (
<span className="text-muted-foreground italic">
waiting...
</span>
)}
</code>
<p className="text-muted-foreground mt-2 text-xs">
Updates after pause
</p>
</div>
<div className="rounded-md border border-green-500/50 bg-green-500/5 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-green-600 dark:text-green-400">
Leading Edge
</span>
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
{leadingUpdates} updates
</span>
</div>
<code className="text-sm">
{leadingValue || (
<span className="text-muted-foreground italic">
waiting...
</span>
)}
</code>
<p className="text-muted-foreground mt-2 text-xs">
Updates immediately, then debounces
</p>
</div>
</div>
<p className="text-muted-foreground text-xs">
<strong>Leading edge</strong> fires immediately on first
keystroke, then debounces. <strong>Trailing edge</strong> waits
for the pause.
</p>
</div>
);
};
API Reference
Hook Signature
function useDebouncedState<T>(
value: T,
options?: UseDebouncedStateOptions,
): UseDebouncedStateReturn<T>;Options
| Property | Type | Default | Description |
|---|---|---|---|
delay | number | 500 | Delay in milliseconds before the debounced value updates |
leading | boolean | false | If true, update immediately on first change |
Return Value
| Property | Type | Description |
|---|---|---|
debouncedValue | T | The debounced value |
isPending | boolean | true when a debounce is waiting to fire |
cancel | () => void | Cancel the pending update, keep current value |
flush | () => void | Immediately apply the pending value |
Common Patterns
Search Input
const [query, setQuery] = useState("");
const { debouncedValue } = useDebouncedState(query, { delay: 300 });
useEffect(() => {
if (debouncedValue) {
fetchSearchResults(debouncedValue);
}
}, [debouncedValue]);Form Validation
const [email, setEmail] = useState("");
const { debouncedValue: debouncedEmail } = useDebouncedState(email, {
delay: 500,
});
useEffect(() => {
if (debouncedEmail) {
validateEmail(debouncedEmail);
}
}, [debouncedEmail]);Hook Source Code
import { useState, useEffect, useCallback, useRef } from "react";
/**
* Options for the useDebouncedState hook
*/
export interface UseDebouncedStateOptions {
/** Delay in milliseconds before the value updates (default: 500) */
delay?: number;
/** If true, update immediately on the first call, then debounce subsequent calls */
leading?: boolean;
}
/**
* Return type for useDebouncedState hook
*/
export interface UseDebouncedStateReturn<T> {
/** The debounced value */
debouncedValue: T;
/** Whether a debounce is pending */
isPending: boolean;
/** Cancel the pending debounce and keep current debounced value */
cancel: () => void;
/** Immediately flush the pending value */
flush: () => void;
}
/**
* A React hook that debounces a value. The debounced value will only update
* after the specified delay has passed without the value changing.
*
* @param value - The value to debounce
* @param options - Configuration options
* @returns UseDebouncedStateReturn object with debounced value and control functions
*
* @example
* ```tsx
* const [searchTerm, setSearchTerm] = useState("");
* const { debouncedValue, isPending } = useDebouncedState(searchTerm, { delay: 300 });
*
* useEffect(() => {
* // This runs only after user stops typing for 300ms
* fetchSearchResults(debouncedValue);
* }, [debouncedValue]);
* ```
*/
export function useDebouncedState<T>(
value: T,
options: UseDebouncedStateOptions = {},
): UseDebouncedStateReturn<T> {
const { delay = 500, leading = false } = options;
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const [isPending, setIsPending] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true);
const latestValue = useRef<T>(value);
// Keep track of the latest value for flush
latestValue.current = value;
// Cancel any pending timeout
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setIsPending(false);
}, []);
// Immediately update to the latest value
const flush = useCallback(() => {
cancel();
setDebouncedValue(latestValue.current);
}, [cancel]);
useEffect(() => {
// Handle leading edge
if (leading && isFirstRender.current) {
isFirstRender.current = false;
setDebouncedValue(value);
return;
}
isFirstRender.current = false;
// Skip if value hasn't changed
if (value === debouncedValue && !isPending) {
return;
}
setIsPending(true);
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
setDebouncedValue(value);
setIsPending(false);
timeoutRef.current = null;
}, delay);
// Cleanup on unmount or when dependencies change
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, delay, leading]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return {
debouncedValue,
isPending,
cancel,
flush,
};
}
export default useDebouncedState;