Aller au contenu principal

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

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

AspectImplementation
Flexible optionsleading, trailing, maxWait
Controlcancel() and flush() exposed
StateisPending for UI feedback
CleanupCleanup of timeouts on unmount
TypeScriptGeneric types for the value
  • /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 library
  • lodash.debounce - With useCallback
  • ahooks - Complete collection of hooks