Custom React Hook
This example shows how to create a professional custom hook with TypeScript and tests.
Command used
/dev:dev-hook "Create a useDebounce hook to delay updates"
Generated structure
src/hooks/
├── useDebounce.ts # Main hook
├── useDebounce.test.ts # Unit tests
└── index.ts # Exports
Hook code
useDebounce.ts
import { useState, useEffect, useRef, useCallback } from 'react';
export interface UseDebounceOptions {
/** Delay in milliseconds (default: 500) */
delay?: number;
/** Execute immediately on first call */
leading?: boolean;
/** Execute at the end of the delay (default: true) */
trailing?: boolean;
/** Maximum wait value before forced execution */
maxWait?: number;
}
export interface UseDebounceReturn<T> {
/** Debounced value */
debouncedValue: T;
/** Cancel the in-progress debounce */
cancel: () => void;
/** Force immediate execution */
flush: () => void;
/** Indicates whether a debounce is pending */
isPending: boolean;
}
/**
* Hook to delay updating a value.
*
* Useful for searches, validations, or any operation
* that should not run on every keystroke.
*
* @example
* ```tsx
* function SearchInput() {
* const [query, setQuery] = useState('');
* const { debouncedValue, isPending } = useDebounce(query, { delay: 300 });
*
* useEffect(() => {
* if (debouncedValue) {
* searchAPI(debouncedValue);
* }
* }, [debouncedValue]);
*
* return (
* <input
* value={query}
* onChange={(e) => setQuery(e.target.value)}
* placeholder={isPending ? 'Searching...': 'Search'}
* />
* );
* }
* ```
*/
export function useDebounce<T>(
value: T,
options: UseDebounceOptions = {}
): UseDebounceReturn<T> {
const {
delay = 500,
leading = false,
trailing = true,
maxWait,
} = options;
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const [isPending, setIsPending] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const maxTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastValueRef = useRef<T>(value);
const leadingCalledRef = useRef(false);
const clearTimeouts = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (maxTimeoutRef.current) {
clearTimeout(maxTimeoutRef.current);
maxTimeoutRef.current = null;
}
}, []);
const flush = useCallback(() => {
clearTimeouts();
setDebouncedValue(lastValueRef.current);
setIsPending(false);
leadingCalledRef.current = false;
}, [clearTimeouts]);
const cancel = useCallback(() => {
clearTimeouts();
setIsPending(false);
leadingCalledRef.current = false;
}, [clearTimeouts]);
useEffect(() => {
lastValueRef.current = value;
// Leading edge
if (leading && !leadingCalledRef.current) {
setDebouncedValue(value);
leadingCalledRef.current = true;
if (!trailing) {
return;
}
}
setIsPending(true);
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Trailing edge
if (trailing) {
timeoutRef.current = setTimeout(() => {
setDebouncedValue(value);
setIsPending(false);
leadingCalledRef.current = false;
}, delay);
}
// Max wait
if (maxWait && !maxTimeoutRef.current) {
maxTimeoutRef.current = setTimeout(() => {
setDebouncedValue(lastValueRef.current);
setIsPending(false);
leadingCalledRef.current = false;
clearTimeouts();
}, maxWait);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, delay, leading, trailing, maxWait, clearTimeouts]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTimeouts();
};
}, [clearTimeouts]);
return {
debouncedValue,
cancel,
flush,
isPending,
};
}
// Simplified alias for common cases
export function useDebouncedValue<T>(value: T, delay = 500): T {
const { debouncedValue } = useDebounce(value, { delay });
return debouncedValue;
}
useDebounce.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDebounce, useDebouncedValue } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial'));
expect(result.current.debouncedValue).toBe('initial');
});
it('debounces value updates', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, { delay: 500 }),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
// Value not updated yet
expect(result.current.debouncedValue).toBe('initial');
expect(result.current.isPending).toBe(true);
// Advance time
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current.debouncedValue).toBe('updated');
expect(result.current.isPending).toBe(false);
});
it('cancels pending updates on cancel()', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, { delay: 500 }),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
act(() => {
result.current.cancel();
});
act(() => {
jest.advanceTimersByTime(500);
});
// Value remains the old one
expect(result.current.debouncedValue).toBe('initial');
});
it('immediately updates on flush()', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, { delay: 500 }),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
act(() => {
result.current.flush();
});
expect(result.current.debouncedValue).toBe('updated');
expect(result.current.isPending).toBe(false);
});
it('supports leading edge execution', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, { delay: 500, leading: true }),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
// Immediate update on leading edge
expect(result.current.debouncedValue).toBe('updated');
});
it('respects maxWait option', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, { delay: 500, maxWait: 1000 }),
{ initialProps: { value: 'initial' } }
);
// Simulate rapid updates
for (let i = 0; i < 5; i++) {
rerender({ value: `update-${i}` });
act(() => {
jest.advanceTimersByTime(300);
});
}
// After 1000ms (maxWait), the value must be updated
expect(result.current.debouncedValue).not.toBe('initial');
});
});
describe('useDebouncedValue', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('provides simplified API', () => {
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: 'test' } }
);
rerender({ value: 'updated' });
expect(result.current).toBe('test');
act(() => {
jest.advanceTimersByTime(300);
});
expect(result.current).toBe('updated');
});
});
index.ts
export { useDebounce, useDebouncedValue } from './useDebounce';
export type { UseDebounceOptions, UseDebounceReturn } from './useDebounce';
Usage example
Real-time search
import { useState, useEffect } from 'react';
import { useDebounce } from '@/hooks';
function SearchUsers() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const { debouncedValue, isPending } = useDebounce(query, {
delay: 300,
leading: false,
});
useEffect(() => {
if (!debouncedValue.trim()) {
setResults([]);
return;
}
const searchUsers = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/users?q=${debouncedValue}`);
const data = await response.json();
setResults(data);
} finally {
setIsLoading(false);
}
};
searchUsers();
}, [debouncedValue]);
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search a user..."
/>
{(isPending || isLoading) && <Spinner />}
<ul>
{results.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Form validation
import { useDebounce } from '@/hooks';
function EmailInput({ onValidate }) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const { debouncedValue } = useDebounce(email, { delay: 500 });
useEffect(() => {
if (!debouncedValue) {
setError('');
return;
}
// Asynchronous validation
checkEmailAvailability(debouncedValue)
.then((available) => {
setError(available ? '': 'Email already in use');
onValidate(available);
});
}, [debouncedValue, onValidate]);
return (
<div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{error && <span className="error">{error}</span>}
</div>
);
}
Key points
| Aspect | Implementation |
|---|---|
| Flexible options | leading, trailing, maxWait |
| Control | cancel() and flush() exposed |
| State | isPending for UI feedback |
| Cleanup | Cleanup of timeouts on unmount |
| TypeScript | Generic types for the value |
Related commands
/dev:dev-test- Add more tests/dev:dev-component- Create a component using this hook/doc:doc-explain- Understand how it works
Alternative libraries
For more advanced cases, consider:
use-debounce- Popular librarylodash.debounce- WithuseCallbackahooks- Complete collection of hooks