Debounced input & autosave with timers
- Write a debounce that keeps its setTimeout handle in a ref and cancels it on each keystroke
- Predict the stale value a debounced save sends when it reads state instead of e.target.value
- Write a blur commit that cancels the pending timer and dedupes against a lastCommitted ref
- Explain why unmounting does not cancel timers, and write the empty-deps cleanup that does
- Choose debounce or throttle from a requirement: once after activity stops vs capped rate during it
Read
setTimeout hands you a receipt you have to keep
FlagsetTimeout(fn, 1000) schedules fn to run once, later, and immediately returns a
handle — a receipt. The only way to cancel that pending call is to hand the receipt
back: clearTimeout(handle).
const handle = setTimeout(() => save("hello"), 1000);
clearTimeout(handle); // never runs
That's the whole idea behind a debounce: every keystroke tears up the old receipt and takes a new one. Only the keystroke that isn't followed by another one within the delay survives to actually run.
let handle;
const scheduleSave = (value) => {
clearTimeout(handle); // cancel the previous, still-pending save
handle = setTimeout(() => save(value), 1000);
};
Type h, he, hel, hell, hello quickly and save is called once, with
"hello" — not five times.
The whole autosave input, line by line
FlagA rename field that saves a second after you stop typing, and immediately when you leave the field. Four moving parts: local state for what you see, a ref for the pending timer, a ref for what you last saved, and an effect whose only job is cleanup.
const EditableTitle = ({ title, onCommit }: { title: string; onCommit: (t: string) => void }) => {
const [text, setText] = useState(title);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastCommitted = useRef(title);
// One door for every save, so "did we already save this?" is asked exactly once.
const commit = (value: string): void => {
if (value === lastCommitted.current) return;
lastCommitted.current = value;
onCommit(value);
};
const cancelPending = (): void => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
};
const scheduleCommit = (value: string): void => {
cancelPending();
timer.current = setTimeout(() => {
timer.current = undefined; // it fired — there is nothing left to cancel
commit(value);
}, 1000);
};
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
return (
<input
value={text}
onChange={(e) => { setText(e.target.value); scheduleCommit(e.target.value); }}
onBlur={() => { cancelPending(); commit(text); }}
aria-label="Collection title"
data-test-id="collection-title"
/>
);
};
Why each piece is the way it is:
setTextandscheduleCommitinonChange. The input must stay responsive on every keystroke (state), while the network write is deferred (timer). Two different clocks for two different jobs.scheduleCommit(e.target.value), notscheduleCommit(text).textis still the previous render's value inside this handler. Read the fresh value off the event.onBlurcancels, then commits. Leaving the field is an explicit "I'm done" — don't make the user wait out the delay. Cancel first so the pending timer can't fire a second, redundant save.- The guard compares against
lastCommitted, nottitle. This is the subtle one. A temptingif (text !== title) onCommit(text)on blur is wrong twice over: after the debounce has already saved,titlemay not have caught up (the parent persists asynchronously, or doesn't feed the value back at all) so you save the same text twice; and if the user edits, saves, then types the original title back,text === titleand the real change is never saved. A ref holding what you actually committed answers the only question that matters: "is this different from what the server has?" - Clearing
timer.currentwhen the timer fires keeps "is a save pending?" honest. A handle left behind is a stale receipt, andclearTimeouton it silently does nothing. - The empty-dep effect returns only a cleanup. On unmount, kill a pending timer:
otherwise it fires against an unmounted component and calls
onCommitfor a screen the user already left.
Unmount flush, out-of-order saves, and debounce vs throttle
FlagCancel on unmount, or flush on unmount?
The version above cancels the pending save when the component unmounts — the last edits are dropped. That's the safe default for a field that also commits on blur: clicking a Close or Save button takes focus off the input first, so blur commits before the unmount. If the field can be unmounted while focused — a modal closed with Esc, a route change — you may want to flush instead.
The catch: an empty-dep cleanup closes over the first render's text and commit
forever. Since React 19.2, an Effect Event is the tool for reading the latest values from
an effect without making them dependencies:
// React 19.2+
const flushPending = useEffectEvent(() => {
if (!timer.current) return; // nothing was pending — the timer already saved it
clearTimeout(timer.current);
timer.current = undefined;
commit(text); // the latest text and commit, not the first render's
});
useEffect(() => () => flushPending(), []); // the cleanup runs it on unmount
The cleanup is part of the Effect, so this keeps the rule that Effect Events are called only
from inside Effects (the react-hooks lint rule accepts it here, and rejects the same call
from onBlur — blur keeps calling commit directly). Two details carry the weight here:
- Flush only when a save was genuinely pending.
timer.currentis that flag — which only works if you set it toundefinedwhen the timer fires and when you cancel. Otherwise unmount flushes a value the debounce already wrote. - Route the flush through the same
commit(thelastCommitteddedupe from the medium example). Belt and braces: even if the "pending?" check is wrong, the value can't be sent twice.
In development, StrictMode's extra mount cycle runs this cleanup once right after mount. Nothing is pending then, so the guard makes it a no-op.
Quiz
You're debouncing an autosave inside a component. Where should the setTimeout handle be stored?
FlagIn onChange={(e) => { setText(e.target.value); scheduleCommit(???) }}, why must you pass e.target.value rather than text?
What is the empty-dependency useEffect(() => () => clearTimeout(timer.current), []) actually protecting you from?
Why does onBlur call clearTimeout before committing?
The requirement is "update the progress bar as the user drags, but no more than 10 times a second." Debounce or throttle?
FlagFill in the blank
Complete the debounce: cancel any pending timer, then schedule a new commit 1s out. Store the handle so the next keystroke can cancel it.
Flagconst timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const scheduleCommit = (value: string): void => { if (timer.current) (timer.current); timer. = (() => onCommit(value), 1000); };
Kill any pending timer when the component unmounts — an effect that returns only a cleanup function and never re-runs.
FlaguseEffect(() => { if (timer.current) clearTimeout(timer.current); }, );
Commit without ever saving the same value twice. Fill in the dedupe guard and the ref it compares against.
Flagconst lastCommitted = useRef(title); const commit = (value: string): void => { if (value lastCommitted.current) return; lastCommitted. = value; (value); };
Predict the output
function Title({ onCommit }) {
const [text, setText] = useState("");
let timer; // NOT a ref
const schedule = (value) => {
clearTimeout(timer);
timer = setTimeout(() => onCommit(value), 1000);
};
return <input value={text} onChange={(e) => { setText(e.target.value); schedule(e.target.value); }} />;
}
// The user types "hello" (5 keystrokes) quickly, then waits.const schedule = (/* no argument */) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => onCommit(text), 1000); // reads state
};
<input value={text} onChange={(e) => { setText(e.target.value); schedule(); }} />
// The user types "hello" quickly and stops.// No cleanup effect. The user types one character into the rename field, // then immediately closes the modal (the component unmounts) 200ms later. timer.current = setTimeout(() => onCommit(value), 1000);
Challenge
Debounce the rename field's autosave
FlagEditableTitle is an inline rename field. It calls onCommit — a network write, in the
real app — on every keystroke. Typing Hello sends five saves, and the last one can
even land out of order.
Turn it into a debounced autosave.
Your task
Edit EditableTitle.tsx so the tests pass:
- The input still updates on every keystroke (it stays responsive)
- No
onCommitwhile the user is still typing - Exactly one
onCommit, with the final value, once typing stops forSAVE_DELAY_MS - Blur commits immediately and only once (the pending timer is cancelled first)
- Blur with no edit commits nothing
- A blur after the debounce already saved does not save the same value again
- Editing again after a save does commit the new value
- Two rename fields on the same page debounce independently
- Unmounting cancels a pending commit