React Patterns & Best PracticesEntry 8 of 10
Debouncing User Input
Debouncing delays an action until the user stops triggering it for a set time, e.g. filtering only after typing pauses for 500 ms.
Notes
- Without it, every keystroke updates state and re-filters (or sends a request).
- Course approach: keep the timer ID in a
useRef, clear the previous timer on each change, and start a new one. useRefis used (not a variable) so the timer survives re-renders without causing them.- Clear the timer on unmount so it doesn't set state on an unmounted component.
- For expensive rendering (not requests),
useDeferredValueis a built-in alternative that lets React render the slow result at lower priority.
What / Why / When
- What
Rate-limiting updates from rapid input.
- Why
Fewer renders and fewer HTTP requests.
- When
Search boxes, autosave, resize/scroll handlers.
Code compare
// components/SearchableList.tsx (debounced)import { useEffect, useRef, useState, type ChangeEvent } from "react";export default function SearchableList<T>({ items, itemKeyFn, children }: SearchableListProps<T>) { const lastChange = useRef<ReturnType<typeof setTimeout> | null>(null); const [searchTerm, setSearchTerm] = useState(""); useEffect(() => { return () => { if (lastChange.current) clearTimeout(lastChange.current); // cleanup on unmount }; }, []); function handleChange(event: ChangeEvent<HTMLInputElement>) { const value = event.target.value; if (lastChange.current) clearTimeout(lastChange.current); lastChange.current = setTimeout(() => { lastChange.current = null; setSearchTerm(value); }, 500); } const searchResults = items.filter((item) => JSON.stringify(item).toLowerCase().includes(searchTerm.toLowerCase()), ); // ...same JSX as Entry 6}// hooks/useDebouncedValue.ts — reusable versionimport { useEffect, useState } from "react";export function useDebouncedValue<T>(value: T, delay = 500) { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced;}RxJS debounceTime is built for this. Bridge signals and Observables with toObservable and toSignal.
// searchable-list.component.ts (debounced)import { toObservable, toSignal } from "@angular/core/rxjs-interop";import { debounceTime, distinctUntilChanged } from "rxjs";export class SearchableListComponent<T> { // ...inputs from Entry 6 private searchInput = signal(""); protected searchTerm = toSignal( toObservable(this.searchInput).pipe(debounceTime(500), distinctUntilChanged()), { initialValue: "" }, ); protected searchResults = computed(() => this.items().filter((item) => JSON.stringify(item).toLowerCase().includes(this.searchTerm().toLowerCase()), ), ); onSearch(event: Event) { this.searchInput.set((event.target as HTMLInputElement).value); }}Differences and gotchas
toSignalunsubscribes automatically when the component is destroyed; no manual cleanup.distinctUntilChangedskips work when the debounced value didn't actually change. In React,setStatewith the same value already bails out.
Quick reference
| React | Angular |
|---|---|
useRef timer + clearTimeout | debounceTime(500) |
useDebouncedValue(value) hook | toSignal(toObservable(sig).pipe(debounceTime())) |
Cleanup in useEffect return | Automatic with toSignal |
useDeferredValue (render priority) | — |