Build a hook that returns a debounced copy of a value. Debouncing means waiting for activity to settle before reacting: as a value changes rapidly, you ignore every intermediate update and only commit once the changes have stopped for a moment. The classic case is a search box — the user types r, re, rea, reac, react, but you only want to fire one search, for react, after they pause. useDebounce(value, delay) gives you that settled value: it mirrors the latest value, but only updates after delay milliseconds have passed with no further changes.
function useDebounce<T>(value: T, delay: number): T;
The returned value tracks value but lags behind it, updating only after delay ms of quiet.
function Search() {
const [text, setText] = useState('');
const debounced = useDebounce(text, 300);
// Fires only after the user stops typing for 300ms.
useEffect(() => {
if (debounced) fetchResults(debounced);
}, [debounced]);
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
// A burst of changes collapses into a single, final update:
// value: 'r' 're' 'rea' 'reac' 'react' (then a pause)
// debounced: '' '' '' '' '' → 'react'
// Only the LAST value is ever committed; the intermediates are skipped.
value right away — no delay on mount.value changes several times faster than delay, the debounced value must skip every intermediate and commit only the final one, once the changes stop.value restarts the quiet period from zero; the hook commits only after delay ms with no further change.'', 0, or false must commit like any other — "no change yet" and "changed to something falsy" are different.You'll mirror a value into state but defer the update with a timer, cancelling and rescheduling that timer on every change so only the final value, after a quiet period, ever commits.
Your user is typing in a search box. The text changes on every keystroke — r, re, rea, reac, react — but you don't want to react to each one; you want to wait until they've stopped typing for a moment and then act on the final word. Debouncing is exactly that "wait for it to settle" behavior. useDebounce(value, delay) gives you a second value that tracks the live one but only catches up once delay milliseconds have passed with no further changes. The hard part isn't scheduling a delayed update — it's making sure a fresh change cancels the update that was already in flight, so a rapid burst collapses into a single commit instead of a stutter of intermediate values.
Keep the debounced value in state, separate from the incoming value. Each time value changes, start a timer that will copy the new value into that state after delay ms. The trick is what happens when another change arrives before the timer fires: you must cancel the pending timer and start a new one. Picture it as a countdown that resets to full on every keystroke — it only reaches zero, and commits, once the keystrokes stop. In React, "do something when value changes, and undo it before the next change" is precisely what a useEffect with a cleanup function expresses.
The obvious version mirrors the value into state and schedules the update inside an effect:
const { useState, useEffect } = require('react');
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
setTimeout(() => setDebounced(value), delay);
}, [value, delay]); // schedule an update whenever value changes
return debounced;
}
This returns the initial value correctly and even works if changes are slow and well-spaced. But it has no way to cancel a pending update. Every change to value schedules another setTimeout, and none of the earlier ones are ever cleared. So a burst of five keystrokes schedules five separate timers — and delay ms later, all five fire in order, dragging debounced through r, re, rea, reac, react one after another. You wanted one commit; you got five. The whole point of debouncing — suppressing the intermediates — is lost.
const { useState, useEffect } = require('react');
function useDebounce(value, delay) {
// The debounced value lives in state, seeded with the initial value so the
// first render returns it immediately — there's nothing to wait for yet.
const [debounced, setDebounced] = useState(value);
useEffect(() => {
// Schedule the commit for `delay` ms from now.
const id = setTimeout(() => setDebounced(value), delay);
// Cleanup runs before the effect re-runs (i.e. on the NEXT change) and on
// unmount. Clearing the pending timer here is what makes this a debounce:
// a change that arrives early cancels the in-flight commit, so only the
// final value's timer ever survives to fire.
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
module.exports = { useDebounce };
The one line that turns the naive version into a real debounce is the cleanup: return () => clearTimeout(id). React runs an effect's cleanup right before it re-runs the effect for a new dependency value — so when value changes again, the previous timer is cancelled before the new one is scheduled. At any moment there is exactly one pending timer, and only the most recent change's timer ever lives long enough to fire. The same cleanup runs on unmount, so a pending commit never fires into a dead component.
Take a value that changes a → b → c quickly, with delay = 80:
a. useState(a) seeds debounced to a, so the hook returns a immediately. The effect runs and schedules a timer to set debounced = a at t≈80ms (harmless — it's already a).b at t=20ms. The effect's dependency value changed, so React first runs the cleanup from the previous run: clearTimeout cancels the a timer. Then the effect re-runs and schedules a new timer to set debounced = b at t≈100ms.c at t=40ms. Cleanup again cancels the pending b timer. A fresh timer is scheduled to set debounced = c at t≈120ms.c timer finally fires: setDebounced('c'). Throughout, the hook returned a (the intermediate b never committed), and now returns c.The reader who copies a value through three rapid changes sees the debounced value go straight from a to c, never touching b — exactly because every change cancelled the timer before it could fire.
clearTimeout cleanup. Without return () => clearTimeout(id), every change leaves its timer running; a burst of N changes fires N times and the debounced value steps through every intermediate. Fix: return the cleanup so each change cancels the prior pending timer.useState() (no argument) makes the first render return undefined instead of the real value. Fix: useState(value) so the hook returns the initial value immediately, with no delay on mount.delay out of the dependency array. With [value] only, a changed delay won't restart the timer at the new rate. Fix: depend on [value, delay] so changing either reschedules.value "change" every time, so the timer constantly resets and never fires. Fix: debounce a primitive, or memoize the object so its identity is stable between renders.cancel and flush. A richer hook can return controls to drop the pending update entirely (cancel) or commit it immediately without waiting (flush), useful for "search now" buttons.useDebouncedCallback cousin. Instead of debouncing a value, debounce a function so the callback itself runs at most once per quiet period — closer to the classic debounce utility, but wired into the hook lifecycle for automatic cleanup.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a hook that returns a debounced copy of a value. Debouncing means waiting for activity to settle before reacting: as a value changes rapidly, you ignore every intermediate update and only commit once the changes have stopped for a moment. The classic case is a search box — the user types r, re, rea, reac, react, but you only want to fire one search, for react, after they pause. useDebounce(value, delay) gives you that settled value: it mirrors the latest value, but only updates after delay milliseconds have passed with no further changes.
function useDebounce<T>(value: T, delay: number): T;
The returned value tracks value but lags behind it, updating only after delay ms of quiet.
function Search() {
const [text, setText] = useState('');
const debounced = useDebounce(text, 300);
// Fires only after the user stops typing for 300ms.
useEffect(() => {
if (debounced) fetchResults(debounced);
}, [debounced]);
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
// A burst of changes collapses into a single, final update:
// value: 'r' 're' 'rea' 'reac' 'react' (then a pause)
// debounced: '' '' '' '' '' → 'react'
// Only the LAST value is ever committed; the intermediates are skipped.
value right away — no delay on mount.value changes several times faster than delay, the debounced value must skip every intermediate and commit only the final one, once the changes stop.value restarts the quiet period from zero; the hook commits only after delay ms with no further change.'', 0, or false must commit like any other — "no change yet" and "changed to something falsy" are different.You'll mirror a value into state but defer the update with a timer, cancelling and rescheduling that timer on every change so only the final value, after a quiet period, ever commits.
Your user is typing in a search box. The text changes on every keystroke — r, re, rea, reac, react — but you don't want to react to each one; you want to wait until they've stopped typing for a moment and then act on the final word. Debouncing is exactly that "wait for it to settle" behavior. useDebounce(value, delay) gives you a second value that tracks the live one but only catches up once delay milliseconds have passed with no further changes. The hard part isn't scheduling a delayed update — it's making sure a fresh change cancels the update that was already in flight, so a rapid burst collapses into a single commit instead of a stutter of intermediate values.
Keep the debounced value in state, separate from the incoming value. Each time value changes, start a timer that will copy the new value into that state after delay ms. The trick is what happens when another change arrives before the timer fires: you must cancel the pending timer and start a new one. Picture it as a countdown that resets to full on every keystroke — it only reaches zero, and commits, once the keystrokes stop. In React, "do something when value changes, and undo it before the next change" is precisely what a useEffect with a cleanup function expresses.
The obvious version mirrors the value into state and schedules the update inside an effect:
const { useState, useEffect } = require('react');
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
setTimeout(() => setDebounced(value), delay);
}, [value, delay]); // schedule an update whenever value changes
return debounced;
}
This returns the initial value correctly and even works if changes are slow and well-spaced. But it has no way to cancel a pending update. Every change to value schedules another setTimeout, and none of the earlier ones are ever cleared. So a burst of five keystrokes schedules five separate timers — and delay ms later, all five fire in order, dragging debounced through r, re, rea, reac, react one after another. You wanted one commit; you got five. The whole point of debouncing — suppressing the intermediates — is lost.
const { useState, useEffect } = require('react');
function useDebounce(value, delay) {
// The debounced value lives in state, seeded with the initial value so the
// first render returns it immediately — there's nothing to wait for yet.
const [debounced, setDebounced] = useState(value);
useEffect(() => {
// Schedule the commit for `delay` ms from now.
const id = setTimeout(() => setDebounced(value), delay);
// Cleanup runs before the effect re-runs (i.e. on the NEXT change) and on
// unmount. Clearing the pending timer here is what makes this a debounce:
// a change that arrives early cancels the in-flight commit, so only the
// final value's timer ever survives to fire.
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
module.exports = { useDebounce };
The one line that turns the naive version into a real debounce is the cleanup: return () => clearTimeout(id). React runs an effect's cleanup right before it re-runs the effect for a new dependency value — so when value changes again, the previous timer is cancelled before the new one is scheduled. At any moment there is exactly one pending timer, and only the most recent change's timer ever lives long enough to fire. The same cleanup runs on unmount, so a pending commit never fires into a dead component.
Take a value that changes a → b → c quickly, with delay = 80:
a. useState(a) seeds debounced to a, so the hook returns a immediately. The effect runs and schedules a timer to set debounced = a at t≈80ms (harmless — it's already a).b at t=20ms. The effect's dependency value changed, so React first runs the cleanup from the previous run: clearTimeout cancels the a timer. Then the effect re-runs and schedules a new timer to set debounced = b at t≈100ms.c at t=40ms. Cleanup again cancels the pending b timer. A fresh timer is scheduled to set debounced = c at t≈120ms.c timer finally fires: setDebounced('c'). Throughout, the hook returned a (the intermediate b never committed), and now returns c.The reader who copies a value through three rapid changes sees the debounced value go straight from a to c, never touching b — exactly because every change cancelled the timer before it could fire.
clearTimeout cleanup. Without return () => clearTimeout(id), every change leaves its timer running; a burst of N changes fires N times and the debounced value steps through every intermediate. Fix: return the cleanup so each change cancels the prior pending timer.useState() (no argument) makes the first render return undefined instead of the real value. Fix: useState(value) so the hook returns the initial value immediately, with no delay on mount.delay out of the dependency array. With [value] only, a changed delay won't restart the timer at the new rate. Fix: depend on [value, delay] so changing either reschedules.value "change" every time, so the timer constantly resets and never fires. Fix: debounce a primitive, or memoize the object so its identity is stable between renders.cancel and flush. A richer hook can return controls to drop the pending update entirely (cancel) or commit it immediately without waiting (flush), useful for "search now" buttons.useDebouncedCallback cousin. Instead of debouncing a value, debounce a function so the callback itself runs at most once per quiet period — closer to the classic debounce utility, but wired into the hook lifecycle for automatic cleanup.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.