typescript 102 lines · 3 tabs

Build a Debounced Search Box in React with a Reusable useDebouncedValue Hook

Shared by codesnips Jul 2026
3 tabs
import { useEffect, useState } from "react";

export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState<T>(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}
3 files · typescript Explain with highlit

Debouncing is the standard technique for turning a fast stream of user input into a slow trickle of expensive work. As a user types into a search box, every keystroke would otherwise fire a network request; debouncing waits until typing pauses for a fixed interval before acting, collapsing a burst of events into one. This snippet splits the concern into two collaborating files: a generic timing hook and the component that consumes it.

In useDebouncedValue hook, the hook takes a value and a delay and returns a copy that only updates after the input has been stable for delay milliseconds. It works by storing the delayed value in state and scheduling a setTimeout inside useEffect. The crucial detail is the cleanup function returned from the effect: whenever value changes before the timer fires, React runs cleanup first, which calls clearTimeout and cancels the pending update. That reset-on-change behaviour is what produces the debounce; without it every keystroke would still eventually apply. The hook is fully generic over T, so it debounces strings, objects, or filter tuples equally well.

The second hook, useSearch hook, layers async data-fetching on top of the debounced value. It watches debounced and only issues a request when that settled value changes, so no request is made mid-typing. Each effect run creates an AbortController and passes its signal to fetch; the cleanup aborts the in-flight request when a newer query supersedes it. This prevents a slow earlier response from overwriting a faster later one — a classic race condition in search UIs. AbortError is deliberately swallowed because an aborted request is expected, not a failure.

SearchBox component wires everything together. The raw input lives in query, updated synchronously on every keystroke so the field stays responsive, while useSearch receives that raw value and internally debounces it. This separation keeps the visible input instant while the network stays calm.

The main trade-off is latency versus load: a larger delay means fewer requests but a longer wait before results appear; 300ms is a common compromise. A subtle pitfall is placing the timer logic directly in the component, which couples timing to rendering and is hard to reuse — extracting useDebouncedValue keeps it testable and shareable across features like autosave or resize handlers.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
  <select name="status" class="rounded border p-2">
    <option value="">Any</option>
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>

Filter UI that syncs query params via Stimulus (no front-end router)

rails hotwire stimulus
by Henry Kim 2 tabs
ruby
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = false
    Bullet.bullet_logger = true
    Bullet.console = true

N+1 query detection with Bullet gem

rails performance activerecord
by Alex Kumar 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts

Keyboard shortcuts with Stimulus and Mousetrap

stimulus javascript ux
by Jordan Lee 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Build a Debounced Search Box in React with a Reusable useDebouncedValue Hook — share card
Link copied