Index

Event emitters, debounce and throttle

Two things get asked here and they are asked in different rounds. Publish/subscribe is a design question - what an emitter buys you, what it costs, and why the same shape turns up in Node, in the DOM and in every state library. Debounce and throttle are a typing question, and the trap is that most people can define one and then implement the other by accident. Say the definitions in terms of the LAST call versus the FIRST call and you will never mix them up again.

Publish/subscribe, and what it actually costs

An emitter is a map from event name to a list of functions, plus a method that calls them all. That is the entire implementation, and being able to say so is worth more than any amount of terminology: on pushes onto the list, emit iterates it, off splices out of it.

What it buys is that the publisher does not know its subscribers. A button that emits saved does not import the toast, the analytics call and the cache invalidator, so any of them can be added or removed without touching it. What it costs is exactly the same property in reverse: nothing statically links the emit to the handlers, so control flow is invisible, 'find all callers' stops working, and every listener you forget to remove is a leak that also keeps the whole closure alive.

You meet the same shape three times over: Node's EventEmitter, the DOM's addEventListener, and the subscribe callback in every store - Redux, Zustand, a React useSyncExternalStore. When an interviewer asks you to implement one, they are usually really asking whether you understand the store you use every day.

Debounce and throttle, said so you cannot mix them up

Debounce: wait until the calls STOP. Every new call resets the clock, so the function runs once, N milliseconds after the last call. If the calls never stop, it never runs.

Throttle: run at most once per N milliseconds, no matter how many calls arrive. The clock is not reset by new calls; it runs, then ignores everything until the window is up.

Choose by asking what you want when a burst never ends. During a two-minute continuous scroll a debounced handler fires zero times and a throttled one fires every 100ms - so scroll, mousemove and resize-while-dragging are throttle, and search-as-you-type, autosave and 'validate this field' are debounce. Resize is the one that gets argued about: throttle it if you are repositioning something while it moves, debounce it if you are doing an expensive relayout only when the user has stopped.

Questions

Answer each one yourself before you open it.

  1. 01What is the publish/subscribe pattern?

    Publishers emit named events without knowing who is listening, and subscribers register handlers for a name. The emitter in the middle holds a map from event name to a list of functions and calls them when the event fires.

    The value is the decoupling: the code that publishes has no import of the code that reacts, so you add a fifth listener without editing the publisher. The cost is that the coupling still exists, it is just invisible to your tools - nothing takes you from the emit to the handler.

    Follow-up When would you NOT use it?

    When there is exactly one consumer and it always runs. Then it is a function call wearing a costume, and you have traded a readable stack trace for nothing. Emitters earn their keep at one-to-many, or where the consumer set genuinely varies.

  2. 02Where do you already use an event emitter without calling it that?

    addEventListener in the DOM, EventEmitter in Node - streams, servers, processes are all emitters - and the subscribe function in every state library, including what useSyncExternalStore expects React to be given.

  3. 03Is emit synchronous or asynchronous?

    Synchronous, in both Node and a hand-rolled emitter. emit is a for loop that calls each listener right there, so it returns only after every handler has run.

    Two consequences. A slow listener blocks the emitter and everything after it, so one handler doing a heavy sync parse stalls the whole chain. And a throwing listener propagates out of emit into the publisher's stack, which is usually not what the publisher expected - the common fix is a try/catch inside the loop so one bad subscriber cannot take the rest down.

  4. 04How do you remove a listener you added with an inline arrow function?

    You cannot. off and removeEventListener match by reference, and a fresh arrow is a different function every time. Keep a named reference, or use the signal option on addEventListener and abort it.

    This is the number one source of listener leaks in React: an inline handler added in an effect, and a cleanup that calls off with a new arrow, so the removal silently does nothing and every remount adds another listener. The symptom is a handler that eventually fires eight times.

  5. 05How does once work?

    It registers a wrapper that removes itself before calling the real handler, so the listener list is clean even if the handler throws.

    Removing first, not after, is the detail worth showing. If you call the handler first and it throws, the removal line never runs and the 'once' listener stays registered forever. Also keep the original function on the wrapper - otherwise off(name, original) cannot find the wrapper to remove.

  6. 06What happens if a listener is added or removed while an event is being emitted?

    It depends on whether the implementation iterates a copy. Node copies the listener array before iterating, so a listener added during an emit does not run until the next one, and one removed during an emit still runs if the loop has not reached it yet.

    Iterate the live array and you get skipped listeners: splice out index 0 while the loop is at index 0 and index 1 shifts down into a slot the loop has already passed. Copying with a spread before the loop is one character of defence and it is what an interviewer is looking for.

  7. 07What is special about the error event in Node?

    If an error event is emitted and nothing is listening, Node throws it as an uncaught exception and by default the process dies.

    It is deliberate - a silently swallowed I/O error is worse than a crash. It means every long-lived emitter you create needs an error listener, even if it only logs.

  8. 08Define debounce.

    Delay the call until the calls stop: every new call resets the timer, so it runs once, N milliseconds after the LAST one.

    Follow-up And throttle?

    Run at most once every N milliseconds, ignoring the calls in between. The timer is not reset by new calls - it is tied to the last time the function actually ran.

  9. 09Search-as-you-type, window resize, scroll position, save button. Which gets which?

    Search-as-you-type: debounce, around 300ms - you only want the query the user stopped on. Scroll: throttle, so it keeps firing while they scroll. Resize: throttle if you are moving something as it drags, debounce if the work is an expensive relayout you only need at the end. Save button: neither - you want a disabled button and an in-flight guard, not a timer.

    That last one is the judgement half of the question. Debouncing a save means the user's click did nothing for 300ms with no feedback, and a double-click still ends up as one save only by accident. Disable the control while the request is in flight; that is honest about what is happening.

  10. 10Someone says debounce and throttle are basically the same. What do you say?

    Give them a burst that never ends. In two minutes of continuous scrolling a debounced handler fires zero times and a throttled one fires roughly 1200 times at 100ms. They are opposites in exactly the case that matters.

    The confusion comes from the common case: for a short burst followed by silence, both end up calling the function about once, so they look interchangeable in a demo. The difference only shows up under sustained input, which is the case you shipped to production.

  11. 11What are the leading and trailing edges?

    Trailing runs the function after the wait, using the last arguments - the default for debounce. Leading runs it immediately on the first call and then suppresses the rest of the burst.

    Leading is right when the first event is the meaningful one and the repeats are noise: a double-clicked button, a rapid-fire keyboard shortcut. Trailing is right when the last value is the meaningful one: a search box, a slider. Enabling both is a real option, and the trap in it is the single trailing call after an isolated single call - a naive both-edges implementation fires twice for one event.

  12. 12Why must a debounce be cancellable?

    Because the pending timer outlives the thing that scheduled it. If a component unmounts with a debounced save still pending, the callback fires against a dead component - a state update on an unmounted tree, or a request nobody will read.

    So the returned function gets a cancel (drop the pending call) and usually a flush (run it now). In React, the cleanup function of the effect calls cancel. Being asked to 'add cancel' is the standard second half of the debounce question and it is where most people stall.

  13. 13You debounce a handler inside a React component and it never debounces. Why?

    It is being recreated on every render, so each keystroke gets a brand new timer with a brand new closure and nothing is ever cancelled. The debounced function has to be stable - created once with useMemo/useRef, not inline in the body.

    The same shape breaks throttle and memoize in components for the same reason: the cache or the timer lives in the closure, and a new closure every render means an empty cache every render. And once you make it stable you have to cancel it on unmount, which is the previous question.

    Follow-up What is the modern alternative for a search box?

    Debounce the VALUE, not the callback: a useDebouncedValue(query, 300) hook that holds a state value updated by a timer, then drive the fetch off that in an effect. It composes with abort-on-change far more cleanly than a debounced handler does.

  14. 14What is the difference between throttling in the client and rate limiting on a server?

    Throttle is a client-side politeness - it drops your own extra calls. Rate limiting is enforcement - the server counts requests per key over a window and rejects the excess with a 429 and a Retry-After.

    The reason the distinction is worth making is that client throttling is not a control: anyone can open a console and call your endpoint in a loop. Throttle to keep your own UI smooth; rate limit because you do not trust the client.

    Follow-up How would you implement one?

    Token bucket is the usual answer: a counter per key that refills at a fixed rate up to a cap, decremented per request, reject at zero. It allows a short burst and then a steady rate, which is what people actually want. A fixed window counter is simpler and lets through double the limit across a window boundary.

  15. 15What is memoisation, and when is it wrong?

    Caching a function's return value against its arguments so a repeat call skips the work. It is wrong whenever the function is not pure, when the arguments do not serialise into a stable key, or when the cache grows without bound.

    The unbounded growth is the one that bites in production: a memoised function keyed on user ids in a long-lived process is a memory leak with a nice name. Bound it - an LRU, a Map with a size cap, or a WeakMap when the key is an object whose lifetime you want to follow.

    Follow-up How do you build the cache key?

    JSON.stringify(args) is the honest default and you should name its limits out loud: key order matters, undefined disappears, functions and symbols vanish, and it will not survive a cyclic object. For a single object argument, a WeakMap keyed on the reference is better - no serialisation, and it does not pin the object in memory.

  16. 16Is useMemo in React memoisation in this sense?

    Not really. It has a cache of exactly one entry and it is allowed to throw that away whenever React likes, so it is a render-time optimisation, not a guarantee. Real memoisation keeps every result until you evict it.

  17. 17How do you memoise an async function?

    Cache the PROMISE, not the resolved value. Store the promise on the first call so concurrent callers share the one in-flight request, and delete the entry on rejection so a failure is not cached forever.

    Caching the value instead means five components mounting at once all miss the cache and fire five identical requests, because none of them has resolved yet. Caching the promise deduplicates them into one - this is exactly what a data-fetching library's request deduplication is.

  18. 18What is once outside of an emitter?

    A wrapper that runs a function at most one time and returns the first result on every subsequent call. Initialisation, a one-time consent prompt, lazily creating a singleton.

  19. 19How do you debounce something that must not lose the last value?

    That is what trailing edge already gives you - the timer fires with the arguments from the most recent call, not the first. If the burst can be interrupted by an unmount or a navigation, add flush and call it on the way out.

  20. 20Your throttled scroll handler feels laggy. What do you change?

    For anything visual, throttle to the frame instead of to a millisecond count: requestAnimationFrame, one call per paint. A 100ms throttle is six frames of staleness, which reads as lag no matter how cheap the handler is.

    Also check you are not doing layout reads and writes in the same handler - a getBoundingClientRect after a style write forces a synchronous reflow, and no amount of throttling makes that cheap.

  21. 21Do you still need to throttle scroll handlers at all?

    Often no. IntersectionObserver covers 'is this element visible', position: sticky covers the sticky header, and CSS scroll-driven animations cover progress bars - all of them off the main thread. Reach for a scroll listener when nothing declarative fits.

    Worth volunteering, because it answers the question behind the question. Also mention { passive: true } on any scroll or touch listener you do add: without it the browser has to wait for your handler to see whether it calls preventDefault.

  22. 22How would you test a debounce?

    Fake timers. Call the debounced function three times, advance the clock by less than the wait and assert zero calls, advance past it and assert exactly one call with the last arguments.

    Naming fake timers is the point - a test that actually sleeps 300ms is slow and flaky, and it will pass on a fast machine and fail in CI. jest.useFakeTimers() or vi.useFakeTimers(), then advanceTimersByTime.

  23. 23What is the difference between an emitter and a promise?

    A promise settles once and remembers its value, so a handler attached afterwards still fires. An emitter fires many times and remembers nothing - subscribe after the event and you missed it.

    That 'missed it' asymmetry is the real design difference and it is where bugs come from: a listener attached one tick too late gets nothing, silently. Emitters are for streams of events; promises for one result.

  24. 24What is the difference between an emitter and an observable?

    An observable adds composition and lifecycle: operators like map and filter, a completion signal, an error channel, and a subscription object you unsubscribe from. An emitter has none of that - it is a list of functions and a loop.

  25. 25Where do the leaks come from in an emitter-based system?

    Listeners that outlive the thing that added them. The handler closes over the component, the component unmounts, but the emitter still holds the function - so the component and everything it captured stays alive.

    Node's 'possible EventEmitter memory leak detected' warning at eleven listeners exists exactly for this. The fix is to make removal impossible to forget: return an unsubscribe function from your on, or take an AbortSignal, rather than trusting every caller to pass the same reference back to off.

  26. 26Why does on returning an unsubscribe function beat off?

    Because it removes the chance of getting it wrong. The caller never has to hold the same function reference, never has to match the event name a second time, and the cleanup is one call that cannot be attached to the wrong listener.

    It is also exactly what React's effect cleanup wants - subscribe in the effect, return what on gave you, done. This is worth offering unprompted when you implement one; it shows you have used the pattern rather than only read about it.

  27. 27Debounce, throttle, and rAF-throttle. One sentence on choosing.

    Debounce when only the end of the burst matters. Throttle when you need regular updates during the burst. rAF-throttle when what you are updating is pixels, because more than once per frame is work nobody can see.

  28. 28How would you implement a simple pub/sub in twenty lines with no class?

    A Map from name to a Set of handlers. on adds to the set and returns a closure that deletes it; emit iterates a copy of the set. A Set gets you deduplication and O(1) removal for free.

Predict the output

The output is hidden. Predict it before you open it.

on, once, and two emits

const EventEmitter = require('events');
const bus = new EventEmitter();

bus.on('tick', (n) => console.log('on', n));
bus.once('tick', (n) => console.log('once', n));

bus.emit('tick', 1);
bus.emit('tick', 2);
Show the output
on 1
once 1
on 2

Listeners run in registration order, so on before once on the first emit. The once wrapper removed itself before running, so it is gone by the second emit and only the permanent listener fires.

Removing the wrong function

const EventEmitter = require('events');
const bus = new EventEmitter();

bus.on('x', () => console.log('hi'));
bus.off('x', () => console.log('hi'));

bus.emit('x');
console.log('listeners:', bus.listenerCount('x'));
Show the output
hi
listeners: 1

off matches by reference and those are two different function objects that merely look identical, so the removal is a silent no-op. Exactly the same thing happens with removeEventListener and an inline arrow, which is the most common listener leak in React cleanup code.

emit is synchronous

const EventEmitter = require('events');
const bus = new EventEmitter();

bus.on('go', () => console.log('handler'));

console.log('before');
bus.emit('go');
console.log('after');
Show the output
before
handler
after

emit is a for loop over the listener array - it calls each handler on the current stack and returns when they are all done. Nothing is queued, so a slow listener blocks the publisher and a throwing listener throws out of emit.

Listener order and prepend

const EventEmitter = require('events');
const bus = new EventEmitter();

bus.on('e', () => console.log('one'));
bus.prependListener('e', () => console.log('zero'));

bus.emit('e');
Show the output
zero
one

Listeners run in array order and prependListener puts one at the front. Ordering is an implementation detail you should not depend on in application code - if two handlers must run in a set order they are one handler with two steps.

Debounce keeps only the last

function debounce(fn, ms) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

const search = debounce((v) => console.log('search', v), 100);

search('r');
search('re');
search('rea');
search('react');
Show the output
search react

Each call clears the pending timer and starts a new one, so only the last set of arguments survives and the function runs once, 100ms after the final call. Four keystrokes, one request - which is the entire point of a search box debounce.

Throttle keeps only the first per window

function throttle(fn, ms) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last < ms) return;
    last = now;
    fn(...args);
  };
}

const onScroll = throttle((y) => console.log('y', y), 100);

onScroll(0);
onScroll(10);
onScroll(20);
setTimeout(() => onScroll(300), 150);
Show the output
y 0
y 300

The first call fires immediately, the next two land inside the 100ms window and are dropped outright, and the one at 150ms opens a new window. Note what this loses: the value 20 is gone forever. A leading-only throttle never delivers the final position of a gesture, which is why real implementations add a trailing call.

Leading-edge debounce

function debounce(fn, ms, { leading = false } = {}) {
  let t = null;
  return (...args) => {
    const callNow = leading && t === null;
    clearTimeout(t);
    t = setTimeout(() => {
      t = null;
      if (!leading) fn(...args);
    }, ms);
    if (callNow) fn(...args);
  };
}

const fire = debounce((v) => console.log('fire', v), 50, { leading: true });

fire(1);
fire(2);
fire(3);
Show the output
fire 1

It runs on the first call of a burst and then swallows the rest until 50ms of silence resets it. This is the right shape for a submit button or a keyboard shortcut, where the first press is the intent and the repeats are noise - the opposite of a search box.

Memoise with a serialised key

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);
  };
}

let calls = 0;
const add = memoize((a, b) => {
  calls++;
  return a + b;
});

console.log(add(1, 2), add(1, 2), calls);
Show the output
3 3 1

Both calls return 3 and the underlying function ran once. Note cache.has rather than a truthiness check on cache.get - a memoised function that legitimately returns 0, '' or undefined would recompute forever with the naive version.

A cache key that is not the whole key

function memoize(fn) {
  const cache = new Map();
  return (a, b) => {
    if (!cache.has(a)) cache.set(a, fn(a, b));
    return cache.get(a);
  };
}

const add = memoize((a, b) => a + b);

console.log(add(1, 2));
console.log(add(1, 5));
Show the output
3
3

Keying on the first argument only makes the second one invisible, so the second call is a cache hit that returns a wrong answer. The bug never throws and never logs; it just quietly returns stale results, which is how memoisation bugs usually reach production.

Debounce and `this`

function debounce(fn, ms) {
  let t;
  return function (...args) {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, args), ms);
  };
}

const panel = {
  name: 'panel',
  save: debounce(function () {
    console.log('saving', this.name);
  }, 10),
};

panel.save();
Show the output
saving panel

The wrapper is a function, so calling it as panel.save() gives it this === panel; the arrow inside the timer inherits that this and apply forwards it. Make the wrapper an arrow, or call fn(...args) directly, and this is undefined by the time the timer fires - so the same code throws on a method.

once, outside an emitter

function once(fn) {
  let done = false;
  let value;
  return (...args) => {
    if (!done) {
      done = true;
      value = fn(...args);
    }
    return value;
  };
}

let n = 0;
const init = once(() => ++n);

console.log(init(), init(), n);
Show the output
1 1 1

The second call short-circuits and returns the cached result, so the counter is only incremented once. Set the flag BEFORE calling fn, not after - otherwise a reentrant call from inside fn runs it a second time.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

EventEmitter

The whole thing: on, off, once, emit. Forty lines, and the round usually ends with 'now make it not blow up if a listener throws'.

class EventEmitter {
  constructor() {
    this.listeners = new Map(); // name -> Set of handlers
  }

  on(name, handler) {
    if (!this.listeners.has(name)) this.listeners.set(name, new Set());
    this.listeners.get(name).add(handler);
    return () => this.off(name, handler);
  }

  off(name, handler) {
    const set = this.listeners.get(name);
    if (!set) return this;
    set.delete(handler);
    if (set.size === 0) this.listeners.delete(name);
    return this;
  }

  once(name, handler) {
    const wrapper = (...args) => {
      this.off(name, wrapper);
      handler(...args);
    };
    wrapper.original = handler;
    return this.on(name, wrapper);
  }

  emit(name, ...args) {
    const set = this.listeners.get(name);
    if (!set || set.size === 0) return false;

    for (const handler of [...set]) {
      try {
        handler(...args);
      } catch (err) {
        console.error('listener for ' + name + ' threw', err);
      }
    }
    return true;
  }

  listenerCount(name) {
    const set = this.listeners.get(name);
    return set ? set.size : 0;
  }
}
  • on returns an unsubscribe function. Offer this unprompted - it is what makes cleanup impossible to get wrong, and it is exactly what a React effect wants to return.
  • [...set] copies before iterating, so a handler that unsubscribes itself or adds another during the emit cannot corrupt the loop.
  • once removes the wrapper BEFORE calling the handler, so a throwing handler still gets unregistered. wrapper.original is there so off(name, handler) could be extended to find it - say that you know it is missing rather than pretending it is not.
  • A Set gives deduplication and O(1) delete. If they ask why not an array: removal is O(n) and the same function registered twice would fire twice.

debounce, with cancel and flush

The most-asked utility in front-end interviews. The second half is always 'now add cancel'.

function debounce(fn, wait = 300) {
  let timer = null;
  let lastArgs = null;
  let lastThis = null;

  function debounced(...args) {
    lastArgs = args;
    lastThis = this;
    clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      fn.apply(lastThis, lastArgs);
    }, wait);
  }

  debounced.cancel = function () {
    clearTimeout(timer);
    timer = null;
    lastArgs = null;
    lastThis = null;
  };

  debounced.flush = function () {
    if (timer === null) return;
    clearTimeout(timer);
    timer = null;
    fn.apply(lastThis, lastArgs);
  };

  debounced.pending = () => timer !== null;

  return debounced;
}

// const search = debounce((q) => fetchResults(q), 300);
// input.addEventListener('input', (e) => search(e.target.value));
// on unmount: search.cancel();
  • A function declaration rather than an arrow, so this survives when the debounced function is used as a method.
  • cancel is the half people forget, and it is the half that matters in React: a pending timer that fires after unmount updates a component that no longer exists.
  • flush is what you call on a form submit - run the pending save now instead of losing it.
  • In a component, create it once (useMemo with an empty dep array, or a ref) and cancel it in the effect cleanup. Created in the render body it never debounces anything.

throttle, leading and trailing

Ask which edges they want before you write it. The naive leading-only version loses the final call, and that is usually a bug.

function throttle(fn, wait = 100) {
  let lastRun = 0;
  let timer = null;
  let lastArgs = null;

  function throttled(...args) {
    const now = Date.now();
    const remaining = wait - (now - lastRun);
    lastArgs = args;

    if (remaining <= 0) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      lastRun = now;
      fn(...args);
    } else if (timer === null) {
      timer = setTimeout(() => {
        lastRun = Date.now();
        timer = null;
        fn(...lastArgs);
      }, remaining);
    }
  }

  throttled.cancel = function () {
    clearTimeout(timer);
    timer = null;
    lastRun = 0;
  };

  return throttled;
}
  • Leading AND trailing: it fires immediately, then guarantees one more call at the end of the window with the newest arguments. That trailing call is what stops a scroll handler from missing the final position.
  • remaining is computed from the last actual run, not reset by incoming calls - that is the one line that makes this a throttle and not a debounce.
  • For anything visual, say you would use requestAnimationFrame instead: one call per paint, aligned to the frame, and free pausing in background tabs.

memoize with a custom key

Almost always followed by 'what happens when the argument is an object' and 'how does it not grow forever'.

function memoize(fn, keyFn) {
  const cache = new Map();

  function memoized(...args) {
    const key = keyFn ? keyFn(...args) : JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);

    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  }

  memoized.clear = () => cache.clear();
  memoized.size = () => cache.size;

  return memoized;
}

// Async, deduplicating in-flight calls:
function memoizeAsync(fn, keyFn) {
  const cache = new Map();

  return function (...args) {
    const key = keyFn ? keyFn(...args) : JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);

    const promise = Promise.resolve(fn.apply(this, args)).catch((err) => {
      cache.delete(key);
      throw err;
    });

    cache.set(key, promise);
    return promise;
  };
}
  • cache.has before cache.get, so a cached undefined or 0 is still a hit.
  • The async version caches the PROMISE, so five callers during one in-flight request share it instead of firing five requests. That is request deduplication, and it is what every data-fetching library does.
  • cache.delete(key) on rejection - otherwise one network blip is cached as a permanent failure.
  • Say the bound out loud: this Map grows forever. In a long-lived process, cap it (an LRU) or key it on objects with a WeakMap so entries can be collected.

A debounced value hook

The React-shaped version of the question, and the one you actually want in a search box. No debounced callback, no stale closure.

import { useEffect, useState } from 'react';

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

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

  return debounced;
}

// function Search() {
//   const [query, setQuery] = useState('');
//   const debouncedQuery = useDebouncedValue(query, 300);
//
//   useEffect(() => {
//     if (!debouncedQuery) return;
//     const controller = new AbortController();
//     fetchResults(debouncedQuery, controller.signal).then(setResults);
//     return () => controller.abort();
//   }, [debouncedQuery]);
// }
  • The cleanup clearing the timer IS the debounce - a new keystroke changes value, which reruns the effect, which cancels the previous timer. There is no separate debounce utility.
  • It also solves unmount for free: the same cleanup runs on unmount, so there is no pending timer to leak.
  • Pairing it with AbortController in the fetching effect fixes the other half of the race - the last request to be sent is not necessarily the last to arrive.

once

A five-line answer that shows you understand closures. Usually a warm-up before the emitter.

function once(fn) {
  let called = false;
  let result;

  return function (...args) {
    if (called) return result;
    called = true;
    result = fn.apply(this, args);
    fn = null; // let the original be collected
    return result;
  };
}

const init = once(() => {
  console.log('initialising');
  return { ready: true };
});
  • Set called = true before invoking fn, so a reentrant call cannot run it twice.
  • Nulling fn afterwards releases whatever it closed over - a small thing, and exactly the kind of detail that gets noticed.
  • The same shape with a Map instead of a boolean is memoize; say that connection out loud if both come up.
← Callbacks, promises and async awaitVirtual DOM, reconciliation and diffing →