DEV Community

Cover image for Why I Built a useDebounce Custom Hook in Next.js (and Why You Should Too)
Cristina Rodriguez
Cristina Rodriguez

Posted on

Why I Built a useDebounce Custom Hook in Next.js (and Why You Should Too)

If you've ever wired up a search bar in Next.js, you’ve probably hit this problem: every keystroke fires a new API call, triggers a re-render, or runs an expensive calculation. As your app grows, those “harmless little updates” start stacking up—fast.

I’ve been there. I remember watching my terminal fill with logs like a slot machine every time I typed a word. And because I was calling an API, sometimes I even hit rate limits just by typing too quickly.

That’s when I realized: I didn’t need results instantly. I needed results after the user paused typing.

That’s exactly what debouncing solves—and why building a small, reusable useDebounce hook changed the flow of my whole project.


🚨 The Problem: Too Many Updates

React is responsive—which is great—until it isn’t.

Common places this becomes a problem:

  • Search bars that fire API calls on every letter
  • Window resize listeners
  • Live form validation
  • Text fields tied to heavy filtering logic

The consequences:

  • Wasted API calls (and money)
  • Janky UI performance
  • Constant re-renders
  • Hitting provider rate limits
  • Sluggish, frustrating user experience

Here’s a simple example of the problem:

useEffect(() => {
  fetch(`/api/search?q=${query}`);
}, [query]);
Enter fullscreen mode Exit fullscreen mode

If a user types “nextjs,” this runs 6 separate times:

n → ne → nex → next → nextj → nextjs
Enter fullscreen mode Exit fullscreen mode

Your server cries.
Your UI stutters.
You quietly wonder if you picked the wrong career.


😅 Naive Solutions (And Why They Fail)

❌ 1. “Let me just add a timeout…”

This is the classic first attempt:

setTimeout(() => {
  fetch(...)
}, 300);
Enter fullscreen mode Exit fullscreen mode

It seems fine… until you realize:

  • You never cancelled the previous timeout
  • Race conditions appear
  • Multiple calls still fire, just delayed
  • Your component becomes a messy spaghetti bowl

❌ 2. “I’ll track the last value manually”

Trust me: you can do this. You shouldn’t do this.

It leads to bugs, stale values, and callback nightmares.

Debouncing deserves its own tidy, reusable hook.


✅ Enter useDebounce: The Custom Hook That Fixes Everything

Debouncing means:

“Only run this action after the value has stopped changing for X milliseconds.”

That tiny delay (300ms, for example) is enough to:

  • Prevent API spamming
  • Smooth out re-renders
  • Improve UX
  • Save money and bandwidth
  • Make your search bar feel intentional instead of chaotic

This is exactly the kind of logic that belongs inside a Custom Hook.


🧩 How useDebounce Works (with Clear Code + Comments)

Here’s the entire hook, ready to drop into your project:

import { useState, useEffect } from "react";

/**
 * useDebounce — delays updating a value until
 * `delay` milliseconds have passed without changes.
 */
export function useDebounce<T>(value: T, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // Start a timer that updates debouncedValue when delay elapses
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cancel the timer if value changes before delay ends
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The timer resets on every keystroke
  • The update only happens after the pause
  • Cleanup prevents race conditions
  • The hook is fully reusable across components

🔍 Using useDebounce in a Next.js Component

Let’s apply it to a real-world search bar.

"use client";

import { useState, useEffect } from "react";
import { useDebounce } from "@/hooks/useDebounce";

export default function SearchBar() {
  const [query, setQuery] = useState("");

  // Debounce raw user input by 300ms
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (!debouncedQuery) return;

    async function search() {
      const res = await fetch(`/api/search?q=${debouncedQuery}`);
      const data = await res.json();
      console.log("Results:", data);
    }

    search();
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
      className="border p-2"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

What’s happening behind the scenes?

  • query updates instantly → keeps the UI responsive
  • debouncedQuery updates after 300ms → triggers one clean API call
  • No API spam
  • No jittery UI

🌟 Benefits of Using useDebounce

1. Better Performance

No more re-render storms.

2. Fewer API Calls

Your backend (and your wallet) will thank you.

3. Smoother UX

Search results feel stable and intentional.

4. Cleaner Code

Centralized, reusable logic.

5. Reusable Everywhere

Use it for:

  • Search bars
  • Form validation
  • Filtering tables
  • Window resize
  • Drag events
  • Autosaving

🧭 Conclusion

Debouncing is one of those small optimizations that makes a huge difference.
The moment you wrap it in a custom React hook like useDebounce, your app becomes faster, smoother, and easier to maintain.

Once you use it in one component, you’ll find yourself adding it everywhere.

If you’ve built your own version—or used useDebounce in interesting ways—I’d love to hear about it. Drop your thoughts in the comments!

Happy coding! 🚀

Top comments (0)