React Hooks & Rendering
React Hooks & Rendering

Debounced input & autosave with timers

Full · ~30 min
After this unit you can
  • 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

Simple
1 / 2

setTimeout hands you a receipt you have to keep

Flag

setTimeout(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.

Medium

The whole autosave input, line by line

Flag

A 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:

  • setText and scheduleCommit in onChange. 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), not scheduleCommit(text). text is still the previous render's value inside this handler. Read the fresh value off the event.
  • onBlur cancels, 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, not title. This is the subtle one. A tempting if (text !== title) onCommit(text) on blur is wrong twice over: after the debounce has already saved, title may 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 === title and 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.current when the timer fires keeps "is a save pending?" honest. A handle left behind is a stale receipt, and clearTimeout on 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 onCommit for a screen the user already left.
Complex

Unmount flush, out-of-order saves, and debounce vs throttle

Flag

Cancel 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.current is that flag — which only works if you set it to undefined when the timer fires and when you cancel. Otherwise unmount flushes a value the debounce already wrote.
  • Route the flush through the same commit (the lastCommitted dedupe 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?

Flag

In onChange={(e) => { setText(e.target.value); scheduleCommit(???) }}, why must you pass e.target.value rather than text?

Flag

What is the empty-dependency useEffect(() => () => clearTimeout(timer.current), []) actually protecting you from?

Flag

Why does onBlur call clearTimeout before committing?

Flag

The requirement is "update the progress bar as the user drags, but no more than 10 times a second." Debounce or throttle?

Flag

Fill 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.

Flag
const 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.

Flag
useEffect(() =>  { 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.

Flag
const 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.
Flag
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.
Flag
// 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);
Flag

Challenge

Debounce the rename field's autosave

Flag

EditableTitle 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 onCommit while the user is still typing
  • Exactly one onCommit, with the final value, once typing stops for SAVE_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

Loading the in-browser editor…

Further reading