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.
  • useRef is 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), useDeferredValue is 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

React
// 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}
React
// 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.

Angular
// 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

  • toSignal unsubscribes automatically when the component is destroyed; no manual cleanup.
  • distinctUntilChanged skips work when the debounced value didn't actually change. In React, setState with the same value already bails out.

Quick reference

ReactAngular
useRef timer + clearTimeoutdebounceTime(500)
useDebouncedValue(value) hooktoSignal(toObservable(sig).pipe(debounceTime()))
Cleanup in useEffect returnAutomatic with toSignal
useDeferredValue (render priority)—