How to Debounce in React

Intermediate8 min interview
Skills tested:
Implementing a useDebounce custom hook with setTimeout and clearTimeout cleanupUsing the debounced value to gate an API request instead of the raw input valueExplaining what the cleanup function in useEffect does when the value changes rapidlyKnowing that client-side filtering should not be debouncedDistinguishing debounce (delay until pause) from throttle (limit to once per interval)

Advertisement

🧩 Scenario

Debouncing is a common performance interview question. Interviewers look for whether you encapsulate the logic in a hook, whether you understand the cleanup is what makes debouncing work (not just the setTimeout), and whether you know when debouncing is and is not appropriate.

Architecture Walkthrough

What Debouncing Does

Debouncing delays the execution of a function until a specified time has passed since the last call. While calls keep coming in faster than the delay, the timer resets. The function only runs once the calls stop for the full delay duration. For a search input with a 300ms debounce, if the user types "react hooks" and pauses, only one API request fires after the last keystroke, not one per character.

The React implementation uses setTimeout to schedule execution and the useEffect cleanup function to cancel the previous timer when the value changes again. This is the critical part: without the cleanup, every keystroke queues a timer, and all of them fire at their scheduled time, producing the same flood of requests you were trying to prevent.

Building the useDebounce Hook

Extracting debounce into a useDebounce hook is the correct abstraction. The hook takes a value and a delay, maintains an internal delayed copy of the value in state, and returns the delayed copy. The component using the hook only ever sees the debounced value, not the raw input value. The raw input state updates immediately on every keystroke (keeping the controlled input responsive), but the debounced copy only updates after the user pauses.

This separation of concerns is important: the controlled input's value and onChange bind to the raw state; the API request, validation, or any expensive operation binds to the debounced value. The input never feels delayed.

Debounce vs Throttle

Debounce: fire only after the calls stop for a given duration. Best for "wait until the user is done" scenarios: search queries, auto-save, resize-triggered layout calculations. Throttle: fire at most once per interval regardless of how many calls arrive. Best for "limit the rate but don't wait for a pause" scenarios: scroll event handlers, analytics tracking, rate-limited API polling.


Key Code Explained

import { useEffect, useState } from 'react';

// The hook: returns a debounced copy of the value
function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    // Schedule the update
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delayMs);

    // Cleanup: cancel the timer if value changes before delay expires
    // This is what makes debouncing work — without this, every
    // keystroke queues a separate timer that fires independently
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debouncedValue;
}


// Usage: search input that debounces the API call
interface SearchResult {
  id: number;
  title: string;
}

function SearchBar() {
  const [query, setQuery] = useState('');                    // raw value: updates on every keystroke
  const debouncedQuery = useDebounce(query, 300);           // delayed: updates after 300ms pause

  const { data: results, isLoading } = useFetch<SearchResult[]>(
    debouncedQuery.length >= 2
      ? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
      : null   // skip fetch when query is too short
  );

  return (
    <div>
      {/* Controlled input binds to raw query — no delay in typing */}
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
        aria-label="Search"
      />

      {/* Loading state shows after the debounce fires, not on every keystroke */}
      {isLoading && <Spinner />}

      <ul>
        {results?.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  );
}


// What NOT to do: debouncing onChange delays the input itself
function BrokenSearch() {
  const [query, setQuery] = useState('');

  // Wrong: debounce wraps the handler, so the state update is delayed
  // The input does not update immediately on keystroke — it feels broken
  const handleChange = useMemo(
    () => debounce((value: string) => setQuery(value), 300),
    []
  );

  return <input value={query} onChange={(e) => handleChange(e.target.value)} />;
  // Problem: value={query} reflects the delayed state, not the typed value
  // The input appears to lag — each character appears 300ms late
}

The distinction between binding value to raw state versus triggering side effects from debounced state is the core of this pattern. The input always feels instant because setQuery runs on every keystroke. The debounced value only controls what triggers expensive work.


Tradeoffs

ApproachInput latencyAPI requests for "react hooks"Complexity
No debounceInstant11 (one per character)None
300ms debounceInstant1-2 (depending on typing speed)Low
500ms debounceInstant1 (if typed in under 500ms)Low
Throttle 300msInstant3-4 (fires every 300ms while typing)Low

What Interviewers Actually Check

  • Whether you return clearTimeout from useEffect and can explain why it is the debouncing mechanism
  • Whether you bind the input to raw state (instant updates) and only use the debounced value for expensive operations
  • Whether you know not to debounce client-side filtering
  • Whether you know the difference between debounce and throttle
  • Whether you extract the logic to useDebounce rather than inlining it per component

Follow-Up Questions

  1. How would you implement throttle as a useThrottle custom hook? What is the key difference in the useEffect logic?
  2. You are using lodash debounce directly in a component. Why does wrapping it in useCallback matter, and what happens if you do not?
  3. How would you add a minimum query length check so the debounced fetch only fires when the query is at least 2 characters?
  4. The debounce delay is currently hardcoded at 300ms. How would you make it configurable without causing the hook to create a new timer on every render when the delay does not actually change?
  5. How would you cancel a pending debounced fetch if the user clears the input before the delay expires?

Common Candidate Mistakes

  • Not returning clearTimeout from the cleanup function — all pending timers fire independently on every keystroke
  • Debouncing onChange instead of the value — the input delays visual updates, making the field feel broken
  • Debouncing client-side filtering — there is nothing to debounce, just derive the filtered list during render
  • Confusing debounce with throttle — debounce waits for silence, throttle limits frequency
  • Using a bare debounce(fn, 300) call in the render body — creates a new debounce closure on every render, resetting the timer

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you implement useDebounce returning a delayed copy of a value?
  • Can you explain why clearTimeout in the cleanup is what makes debouncing work?
  • Can you wire the debounced value to an API call while keeping the input bound to raw state?
  • Can you explain when to use debounce vs throttle?
  • Can you explain why client-side filtering should not be debounced?

Summary

Debouncing delays function execution until a user pauses for a specified duration. In React, the pattern is a useDebounce hook that uses setTimeout to schedule a state update and returns clearTimeout from useEffect as the cleanup. The cleanup cancels the pending timer when the value changes again — this cancellation is what makes debouncing work. The controlled input binds to the raw state value for instant visual updates. The debounced value controls the API request, auto-save, or other expensive operation. Never debounce client-side filtering: filtering an in-memory array is synchronous and immediate, so debouncing only adds latency. Debounce is for operations with real cost. Throttle is the alternative when you want to limit frequency while the user is actively interacting, rather than waiting for a pause.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Should I debounce client-side filtering?

No. Client-side filtering (filtering an in-memory array) is synchronous and fast. Debouncing it adds latency without reducing any work. Use a derived filtered list computed directly during render. Debouncing is only for operations with real cost: API requests, expensive computations, localStorage writes.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement