diff --git a/text/0228-use-debounce.md b/text/0228-use-debounce.md new file mode 100644 index 00000000..06168b07 --- /dev/null +++ b/text/0228-use-debounce.md @@ -0,0 +1,518 @@ +- Start Date: (2025-09-14) +- RFC PR: (leave this empty) +- React Issue: (leave this empty) + +# Summary + +We propose adding an official useDebounce API to React. This hook delivers the +most commonly needed debounce behavior in a one-liner that is reliable, +concurrent-safe, and easy to teach. Instead of relying on third-party utilities, +React would provide a first-class, correct, and ergonomic solution for this +everyday use case. + +# Basic example + +```jsx +function SearchBox() { + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 300); + + useEffect(() => { + if (debouncedQuery) { + fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`); + } + }, [debouncedQuery]); + + return setQuery(e.target.value)} />; +} +``` + +With just one line, useDebounce ensures we avoid spamming the server while the +user types, yet always fetch the latest intended query. + +# Motivation + +Debouncing is one of the most common patterns in modern UIs: + +- search boxes that should wait until typing pauses before firing a request, +- resize and scroll listeners that should not flood the browser with updates, +- expensive calculations that should only run after user input settles. + +Today, developers rely on userland hooks, ad-hoc implementations, or external +utilities like Lodash. These solutions often: + +- break under **Strict Mode’s intentional double-invocation**, leaking timers or + firing twice, +- behave unpredictably with **Concurrent Rendering**, where stale closures or + premature effects can slip through, +- fail to guarantee proper **cleanup** when dependencies change rapidly, +- diverge in semantics (different interpretations of leading/trailing/maxWait), + making learning inconsistent. + +By providing a **first-class, officially supported hook**, React can: + +- ensure correctness aligned with its lifecycle and scheduling model, +- reduce subtle bugs that even experienced developers make, +- simplify teaching: one canonical API instead of dozens of competing recipes, +- and integrate better with future concurrency features. + +Even if this RFC is not accepted, the underlying motivation remains: React +developers need clear, safe, and modern guidance for implementing debounce +without relying on fragile workarounds. + +# Detailed design + +# API surface + +```ts +// Debounced value: returns a value that updates after a quiet period +export function useDebounce( + value: T, + delay: number, + options?: { + leading?: boolean; // default: false — emit immediately at burst start + trailing?: boolean; // default: true — emit after quiet period + maxWait?: number; // default: undefined — force an update at most every N ms + }, +): T; + +// Debounced callback: returns a stable function you can pass to listeners +export function useDebouncedCallback( + fn: (...args: A) => void, + delay: number, + options?: { + leading?: boolean; // default: false + trailing?: boolean; // default: true + maxWait?: number; // default: undefined + }, +): (...args: A) => void; +``` + +**Defaults:** { leading: false, trailing: true } and maxWait omitted. + +--- + +# Behavioral semantics + +- Burst window. Any sequence of changes/calls closer than delay ms is considered + one _burst_. +- Leading / trailing. + - leading: true → fire once at the start of a burst. + - trailing: true → fire once at the end of a burst (after delay ms of + inactivity). + - If both are true, fire at start and at end (at most twice per burst). +- maxWait. During a very long continuous burst, ensure at least one invocation + every maxWait ms. Resets when an invocation fires. +- Identity stability. + - useDebouncedCallback returns a function whose identity is stable across + renders unless delay or options change. + - useDebounce returns the last committed debounced value; it does not emit + intermediate values between commits. +- Strict Mode. Timers are created in effects and fully cleaned up on unmount and + on re-mount; intentional double-invocation must not cause duplicate + user-visible calls or timer leaks. +- Concurrent Rendering. No side effects occur for renders that do not commit. + Debounced emissions happen after commit, respecting the chosen + leading/trailing semantics. +- SSR. No timers are scheduled on the server. All timing begins on the client + after hydration. +- Cleanup semantics. + - On unmount, pending timers are canceled by default (no forced trailing + flush). + - When delay/options change, pending timers are canceled and rescheduled using + the new configuration. +- Error propagation. Exceptions thrown by the debounced callback propagate to + the caller’s environment when the callback finally runs. + +--- + +# Edge-case matrix + +| leading | trailing | Effect per burst | +| :-----: | :------: | --------------------------------------------------------------------------- | +| false | true | Default. Emit once after quiet period. | +| true | false | Emit immediately at burst start only. | +| true | true | Emit immediately, then again at quiet period end. | +| any | any | With maxWait, ensure at least one emit every maxWait ms during long bursts. | + +Notes: + +- If trailing is false, maxWait only matters when leading is also true. +- Rapidly changing fn or value is supported: the latest refs are used when the + emit occurs. + +--- + +# Usage examples + +Value debouncing (search): + +```ts +const [q, setQ] = useState(""); +const dq = useDebounce(q, 300); +useEffect(() => { + if (dq) fetch(`/api?q=${encodeURIComponent(dq)}`); +}, [dq]); +``` + +Callback debouncing (resize): + +```ts +const onResize = useDebouncedCallback(() => recalcLayout(), 120, { + trailing: true, + maxWait: 1000, +}); +useEffect(() => { + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); +}, [onResize]); +``` + +Leading + trailing: + +```ts +const saveDraft = useDebouncedCallback(syncToServer, 500, { + leading: true, + trailing: true, + maxWait: 5000, +}); +``` + +--- + +# Implementation sketch (non-normative) + +- Feature flag: enableUseDebounce (experimental only). Exports live behind + experimental entry points. +- Refs & timers: + - Keep refs for lastInvokeTime, timerId, maxWaitTimerId, pendingArgs, + pendingValue, optionsRef, and fnRef. + - Update fnRef.current = fn every render; do not recreate the debounced + wrapper unless delay/options change. +- Scheduling: + - On call/change: record pendingArgs/pendingValue, compute whether a leading + invoke should happen (no active burst), otherwise (re)start a delay timer. + - On timer fire: if in a burst and trailing, invoke with the latest ref; clear + timers and timestamps. + - If maxWait is set and no invoke occurred since lastInvokeTime >= maxWait, + force an invoke. +- Effects & cleanup: + - Use an effect to install timers and return a cleanup that cancels both delay + and maxWait timers. + - On unmount or when delay/options change, cleanup cancels timers. +- Stable callback: + - Return a memoized function that reads from refs and manipulates timers. + +Pseudocode (for useDebouncedCallback): + +```ts +function useDebouncedCallback( + fn, + delay, + opts = { leading: false, trailing: true }, +) { + const fnRef = useRef(fn); + fnRef.current = fn; + const cfgRef = useRef(normalize(opts, delay)); + useEffect(() => { + cfgRef.current = normalize(opts, delay); + }, [delay, opts.leading, opts.trailing, opts.maxWait]); + + const state = useRef({ + timer: null, + maxTimer: null, + lastInvoke: undefined, + pending: null, + }); + + useEffect( + () => () => { + clear(state.current.timer); + clear(state.current.maxTimer); + }, + [], + ); + + const debounced = useCallback( + (...args) => { + const now = Date.now(); + const cfg = cfgRef.current; + const s = state.current; + const isInBurst = + s.lastInvoke !== undefined && now - s.lastInvoke < cfg.delay; + const shouldLead = cfg.leading && !isInBurst && s.timer == null; + + s.pending = args; + + if (shouldLead) { + fnRef.current(...s.pending); + s.lastInvoke = Date.now(); + s.pending = null; + } + + clear(s.timer); + if (cfg.trailing) { + s.timer = setTimeout(() => { + if (s.pending) { + fnRef.current(...s.pending); + s.lastInvoke = Date.now(); + s.pending = null; + } + s.timer = null; + }, cfg.delay); + } + + if (cfg.maxWait != null && !s.maxTimer) { + s.maxTimer = setTimeout(() => { + if (s.pending) { + fnRef.current(...s.pending); + s.lastInvoke = Date.now(); + s.pending = null; + } + clear(s.timer); + s.maxTimer = null; + s.timer = null; + }, cfg.maxWait); + } + }, + [delay, opts.leading, opts.trailing, opts.maxWait], + ); + + return debounced; +} +``` + +useDebounce(value, ...) can be built on top of useDebouncedCallback by storing +an internal state and setting it via the debounced callback. + +--- + +# Interactions & guarantees + +- No accidental double calls in Strict Mode. +- No stale closures: latest fn/value read from refs. +- No SSR timers: safe to import in SSR. +- No breaking changes: entirely additive and experimental. + +--- + +# Testing strategy (non-normative) + +- Fake timers for deterministic timing tests. +- Matrix over {leading, trailing} × maxWait presence. +- Strict Mode double-mount tests. +- SSR/hydration: ensure no timers created on server. +- Identity stability: listener re-subscription does not occur unless config + changes. + +# Drawbacks + +- **Implementation cost.** Adding new hooks increases the React codebase surface + area, which means more code to maintain, test, and support across environments + (web, React Native, experimental runtimes). +- **Userland viability.** Debouncing can already be implemented in user space + with small, well-tested hooks or libraries. Including it in core may set a + precedent for adding many other utility hooks (throttle, memoize, etc.). +- **Teaching complexity.** Introducing a new built-in hook expands the API + surface developers must learn. It could blur the lines between React’s core + responsibilities (rendering, scheduling, state management) and general UI + utilities. +- **Integration risks.** We must ensure this feature integrates smoothly with + current and planned features (e.g., transitions, concurrent rendering, future + scheduling APIs). A poorly aligned debounce API could conflict with or obscure + those patterns. +- **Migration cost.** While this is an additive feature (no breaking changes), + many teams already rely on custom debounce hooks or libraries. Introducing a + core version may create “two competing standards” and require teams to decide + whether to migrate. + +In summary, the tradeoff is between offering a **first-party, safe, canonical +API** versus keeping React lean and letting the ecosystem continue to provide +debounce utilities. + +# Alternatives + +- **Status quo (do nothing).** + Keep debouncing in userland. Developers can continue to use existing hooks or + libraries (e.g., lodash, `usehooks-ts`). This avoids increasing React’s + surface area, but leaves room for inconsistency and subtle bugs in Strict Mode + or concurrent rendering. + +- **Official documentation recipes.** + Instead of a new API, React could provide an “official” example of a safe + debounce hook in the docs. This would give developers a canonical reference + without introducing a new built-in hook. + +- **Bless a community library.** + The React team could recommend a well-maintained debounce hook from the + ecosystem. This reduces duplication but introduces an external dependency and + less control over long-term stability. + +- **Use transitions instead.** + Some cases (like deferring visual updates while typing) can be solved with + `useTransition`. However, transitions do not provide strict debounce semantics + (time-based suppression of calls), so they are not a full replacement. + +- **Throttling or rate limiting APIs.** + Alternative designs could add more generic scheduling primitives (throttle, + rate limit). While powerful, this would broaden React’s API surface + significantly and risk scope creep. + +**Impact of not doing this:** + +- Developers will continue writing or importing debounce utilities, with varying + correctness. +- Subtle bugs in concurrent/Strict Mode may persist due to ad-hoc + implementations. +- React loses the chance to provide a unified, reliable solution for one of the + most common UI patterns. + +# Adoption strategy + +- **Breaking changes:** None. This feature is purely additive. Existing + applications and libraries will continue to work without modification. + +- **Opt-in adoption:** Developers can gradually replace their existing debounce + utilities with `useDebounce` or `useDebouncedCallback`. Because the API shape + is similar to common community hooks, migration should be straightforward. + +- **Codemods:** A codemod could help migrate simple cases from popular libraries + (`usehooks-ts`, `ahooks`, lodash debounce wrappers) to the new React API. + However, migration is optional — teams can keep their existing solutions if + desired. + +- **Coordination with libraries:** + - Documentation for React should clarify when to prefer the built-in API + versus transitions or custom utilities. + - Popular hook libraries could align with the React API surface to minimize + confusion and encourage consistency. + +- **Experimental rollout:** + - Initially ship behind a feature flag in the experimental channel. + - Gather feedback from early adopters and the community. + - Promote to stable only after validating ergonomics, semantics, and ecosystem + fit. + +In short, adoption would be **incremental and low-risk**: developers can try the +new API immediately without breaking existing code, and the ecosystem can align +gradually. + +# How we teach this + +## Naming and terminology + +- **Primary names:** `useDebounce` (value-focused) and `useDebouncedCallback` + (callback-focused). + - Rationale: aligns with common ecosystem terms, immediately communicates + behavior. +- **Key distinctions to teach:** + - **Debounce** = wait until input settles, then run once. + - **Throttle** = run at most once per interval. + - **Transition** = lower-priority rendering; not time-based suppression. + +## Positioning within React concepts + +- Present as a **focused timing utility** that complements state/effects — not a + replacement for state management or transitions. +- Emphasize **Strict Mode** and **Concurrent Rendering** compatibility as the + core value vs. ad-hoc solutions. + +## Documentation plan + +- **API Reference pages** for both hooks: + - Clear parameter tables, defaults (`leading: false`, `trailing: true`, + `maxWait: undefined`). + - Behavior timeline diagrams for leading/trailing/maxWait. + - SSR and cleanup semantics (no timers on server, cancel on unmount/change). +- **Guides:** + - _Debounce vs Throttle vs Transition_ — side-by-side comparison and decision + checklist. + - _Common patterns_: search-as-you-type, resize handlers, draft autosave, + expensive calculations. + - _Pitfalls & Gotchas_: stale closures, Strict Mode double-invoke, event + pooling; how these are handled. +- **Examples (copy-paste):** + - Minimal “search box” example (value debounce). + - Event listener example (debounced callback for resize/scroll). + - Leading + trailing + optional `maxWait` example. + - Accessibility note: ensure debouncing does not hide critical feedback. + +## Teaching new developers + +- Introduce after they learn `useState`/`useEffect`. +- Use a simple mental model: “group rapid changes into bursts; emit at start + and/or end of a burst.” +- Provide an interactive playground showing keystrokes vs. debounced emissions + on a timeline. + +## Teaching existing developers + +- Migration note: replacing most userland debounce hooks is a one-line swap. +- Checklist to decide usage: + - Need to **delay** work until input settles? → `useDebounce` / + `useDebouncedCallback`. + - Need to **defer priority** (but not suppress frequency)? → `useTransition`. + - Need to **cap frequency**? → consider throttle (userland). +- Performance & UX guidance: + - Choose `delay` thoughtfully; prefer small values (100–300ms) for responsive + UIs. + - Use `leading` for perceived responsiveness (e.g., draft save), `trailing` + for correctness (latest value). + +## Documentation changes + +- Add a new “Timing & Scheduling Patterns” section under “Managing State” or + “Effects”. +- Cross-link from existing guides that currently show ad-hoc debouncing recipes. +- Add a glossary entry for “burst window” and link it from both APIs. + +## Teaching format and artifacts + +- Short video/gif showing typing vs. debounced fetches. +- Timeline diagrams for each option combo (default, leading-only, + leading+trailing, with `maxWait`). +- Sandbox templates for quick experimentation (search form, resize handler, + autosave). + +# Unresolved questions + +- **Trailing on cleanup:** When a component unmounts or options change + mid-burst, should a pending trailing call be **flushed** or **canceled**? + (Current proposal: cancel.) +- **`maxWait` default & semantics:** Should `maxWait` remain `undefined` by + default? If provided, should it reset on both leading and trailing invokes, or + only on successful invokes? +- **Naming:** `useDebounce` + `useDebouncedCallback` vs. `useDebouncedValue` + (clearer for value form?). Any risk of confusion with community naming? +- **Identity guarantees:** Is the callback identity strictly stable across + renders unless `delay/options` change, or should we allow advanced opt-outs + (e.g., `stable: false`)? +- **Event objects:** For React Synthetic Events passed into debounced callbacks, + what guidance is required (e.g., read fields synchronously or persist/clone)? + Should the docs enforce best practices? +- **Transitions interplay:** Should examples encourage `startTransition` inside + the debounced callback for non-urgent rendering, or keep concerns separate? +- **Priority/scheduling alignment:** Do we need hooks into internal priorities + (present/future) so debounce emissions can opt into specific lanes/queues? +- **SSR/hydration nuances:** Any edge cases where hydration mismatches could + occur if the value form (`useDebounce`) feeds into layout-sensitive work? +- **React Native & alternative runtimes:** Timer precision and background + throttling differ on mobile—do we need platform-specific notes or fallbacks? +- **DevTools visibility:** Should DevTools surface debounced emissions + distinctly (e.g., annotate updates as “debounced”)? +- **Error surfaces:** If the debounced callback throws, do we need additional + guidance on error boundaries vs. async error propagation? +- **Abort/cancel API:** Do we expose an escape hatch to **flush** or **cancel** + programmatically (e.g., returning `{ runNow, cancel }`), or is that + out-of-scope for v1? +- **Performance budget & code size:** What is the acceptable code size increase + for core/experimental bundles? Any micro-optimizations needed for hot paths? +- **Docs scope:** Do we include throttle guidance alongside debounce, or keep + throttle as ecosystem territory to limit scope creep? +- **Telemetry/feedback loop:** Do we need optional signals (in experiments) to + learn common option mixes (`leading/trailing/maxWait`) before stabilizing? +- **Accessibility guidance:** Any recommended patterns to avoid delaying + critical feedback (e.g., validation) or ARIA live region updates? +- **Alternative API shapes:** Should we consider a single multipurpose hook + (e.g., `useRateLimit({ mode: 'debounce' | 'throttle' })`) vs. specialized + debounce-only APIs?