debounce-merge
v0.2.0
Published
Debounce function calls per object id, merging accumulated arguments until the timeout fires
Maintainers
Readme
debounce-merge
Debounce calls to a function per object id, deep-merging the arguments of every call that happens inside the debounce window. Only the last call in a window actually runs but it runs with the merged arguments of everyone who called during that window, and everyone gets the same result back.
Why
Say you have a function that saves a user to the server:
function saveUser(user: { id: string }, patch: Partial<User>) {
return api.patch(`/users/${user.id}`, patch);
}- If several parts of your UI call
saveUserfor the same user in quick succession (e.g. while the user is typing across a few fields), you usually don't want to fire a request per keystroke, and you don't want the second request to blow away fields the first one just set.createDeferredgives you both: calls for the sameuser.idare batched into one request, with the patches merged together.
Calls for a different user.id are completely independent - they get their
own timer and don't interfere with each other.
- The React Unmount Problem (Global Safety) A very common issue in React is that if you declare a debounced save function inside a component (or use a standard hook), the timer is tied to the component's lifecycle. If the user makes an edit and immediately navigates away or selects a different object on the screen, the component unmounts, the timer gets destroyed, and the final save is lost.
With debounce-merge, you declare your debounced function globally, outside of the React component tree. Because it automatically routes and isolates timers based on the id, you don't need to instantiate a new debouncer per component. When a component unmounts, the global timer for that id keeps ticking safely in the background, guaranteeing your data is saved.
Install
npm install debounce-mergeQuick example
import { createDeferred } from "debounce-merge";
type User = { id: string; name: string; age: number };
function saveUser(user: { id: string }, patch: Partial<User>) {
console.log("saving", user.id, patch);
return api.patch(`/users/${user.id}`, patch);
}
const deferredSaveUser = createDeferred(saveUser, 300); // 300ms window
deferredSaveUser({ id: "u1" }, { name: "Alice" });
deferredSaveUser({ id: "u1" }, { age: 30 });
// 300ms later, exactly ONE call happens:
// saving u1 { name: "Alice", age: 30 }
// Both calls above resolve with the same result, once that one call finishes.Calls for a different id run independently and are not merged with the above:
deferredSaveUser({ id: "u2" }, { name: "Bob" }); // separate timer, separate callHow it works
- Groups by
id- Calls sharing the same objectidare batched independently (or pooled into a shared batch if noidis present). - Deep-merges arguments - All arguments passed within the debounce window are combined into a single set.
- Executes once - When the timer fires,
fnruns once with the merged payload and returns the result to all callers in that batch.
call A ──┐
call B ──┼─ merged args -> (timer fires) -> fn(merged) -> same result to A, B, C
call C ──┘API
createDeferred(fn, timeoutMs?)
function createDeferred<TFunc extends (...args: any[]) => any>(
fn: TFunc,
timeoutMs?: number, // default: 300
): (...args: Parameters<TFunc>) => Promise<ReturnType<TFunc>>;fn- the function to debounce. Its parameters and return type are picked up automatically - the function returned bycreateDeferredhas the exact same parameter types asfn, and resolves tofn's return type.timeoutMs- debounce window in milliseconds. Defaults to300.
Returns a function you call exactly like fn, except it returns a Promise.
Pivoting rules
| First argument of fn | Pivot used |
| --------------------------- | ------------------------------------ |
| { id: "abc", ... } | "abc" - batched with other calls sharing this id |
| { id: 42, ... } | 42 - same rule, numeric ids work too |
| anything else (or no args) | "common" - all such calls share one batch |
Exported types
import type { CreateDeferred, ObjectWithId, Id } from "debounce-merge";
import { AbortError } from "debounce-merge";Id-string | number. The type accepted for an object'sidfield.ObjectWithId-{ id: Id }. Shape checked on the first argument to decide the pivot.CreateDeferred<TArgs, TReturn>- the type of the function returned bycreateDeferred, in case you want to store it in a typed variable:
const deferredSaveUser: CreateDeferred<[user: { id: string }, patch: Partial<User>], Response> =
createDeferred(saveUser, 300);AbortError- the error class a pending call's promise rejects with when it's discarded viacancelPendingChangesForPivot(). ExtendsError. See that section for details.
applyAllPendingChanges()
function applyAllPendingChanges(): Promise<PromiseSettledResult<unknown>[]>;Immediately flushes every pending debounced call - across every pivot and
every debounced function - instead of waiting for its timeoutMs window to
elapse. Useful for moments where you can't afford to wait, e.g. before the
page unloads, on manual "Save now", or when unmounting the whole app.
For each pending call, it:
- Cancels the scheduled timer, so it won't fire again later.
- Calls the original function right away, with the merged arguments accumulated so far.
- Resolves or rejects the exact same promise already handed back by
createDeferred. If the function is async and returns aPromise, that result is unwrapped automatically - callers get the final value
The returned promise resolves via Promise.allSettled.
If nothing is pending, it resolves to [].
const deferredSaveUser = createDeferred(saveUser, 5000); // 5s window
deferredSaveUser({ id: "u1" }, { name: "Alice" });
deferredSaveUser({ id: "u2" }, { name: "Bob" });
// User is closing the tab - can't wait 5 seconds:
const results = await applyAllPendingChanges();
// [{ status: "fulfilled", value: ... }, { status: "fulfilled", value: ... }]Note: this is a flush, not a cancel - pending calls are run, not discarded. The order of entries in the returned array is not guaranteed to match the order calls were originally made in.
Internally, this just loops over every pivot currently pending and calls
applyPendingChangesForPivot() on
each one - if you only need to flush a single pivot, use that instead.
applyPendingChangesForPivot(pivotId)
function applyPendingChangesForPivot(
pivotId: Id,
): Promise<PromiseSettledResult<unknown>[]>;The same flush behavior as applyAllPendingChanges(),
scoped to a single pivot. Every debounced function that has a pending call
for that pivotId is flushed immediately; every other pivot is left
untouched, still waiting out its own timer.
Useful when you know exactly which record you're done editing and want to save it right away, without forcing every other unrelated pending save in your app to fire early too.
const deferredSaveUser = createDeferred(saveUser, 5000); // 5s window
deferredSaveUser({ id: "u1" }, { name: "Alice" });
deferredSaveUser({ id: "u2" }, { name: "Bob" });
// User just finished editing u1 and moved on - flush only their save.
// u2's pending call is untouched and still has up to 5s left on its timer.
const results = await applyPendingChangesForPivot("u1");
// [{ status: "fulfilled", value: ... }]If the given pivotId has nothing pending, it resolves to [] - same as
applyAllPendingChanges() does when the whole store is empty.
cancelPendingChangesForPivot(pivotId)
function cancelPendingChangesForPivot(pivotId: Id): void;Discards every pending debounced call for a single pivot, without ever
calling the underlying function. This is the opposite of
applyPendingChangesForPivot(): that
one runs what's pending, this one throws it away.
For each pending call tied to pivotId, it:
- Cancels the scheduled timer, so it won't fire later.
- Rejects the exact same promise already handed back by
createDeferred, with anAbortError. - Clears the accumulated (merged) arguments for that pivot - they are gone, not carried over to the next call.
Other pivots are completely unaffected and keep running on their own timers.
const deferredSaveUser = createDeferred(saveUser, 5000); // 5s window
const savePromise = deferredSaveUser({ id: "u1" }, { name: "Alice" });
// User discarded their edits before the debounce window elapsed -
// don't save anything for u1.
cancelPendingChangesForPivot("u1");
await savePromise; // rejects with AbortErrorIf the given pivotId has nothing pending (including if it was already
flushed or cancelled), this is a no-op.
Note: unlike
applyAllPendingChanges(), there is currently nocancelAllPendingChanges()helper that cancels every pivot at once - open an issue if you need one.
AbortError
The error class every promise rejects with when its pivot is cancelled via
cancelPendingChangesForPivot().
class AbortError extends Error {
readonly pivotId: Id;
readonly functionId: number;
}nameis"AbortError".pivotIdis the pivot that was cancelled.functionIdis an internal id identifying which debounced function (as returned bycreateDeferred) the rejected call belonged to.
try {
await deferredSaveUser({ id: "u1" }, { name: "Alice" });
} catch (err) {
if (err instanceof AbortError) {
console.log(`save for ${err.pivotId} was cancelled`);
} else {
throw err;
}
}Using with React (or any component framework)
createDeferred must be created once and reused - not recreated on every
render. The pending-change store (changeStore) lives at module scope and is
keyed by an internal id that's generated the moment createDeferred runs, not
on every call. If you call createDeferred inside a component body, every
re-render produces a brand new internal id, so calls made before and after
that re-render land in different, unrelated buckets and never merge -
silently defeating the whole point of this library, without throwing any
error.
// Bad - a new debounced function (and a new internal id) is created
// on every render. Keystrokes before and after a re-render never merge.
function UserForm({ user }: { user: User }) {
const deferredSave = createDeferred(saveUser, 300);
return (
<input onChange={(e) => deferredSave(user, { name: e.target.value })} />
);
}// Good - created once, outside any component. Since batching is already
// keyed by the object's `id`, one shared instance safely handles every
// user, in every component, at once.
// userService.ts
export const deferredSaveUser = createDeferred(saveUser, 300);
// UserForm.tsx
function UserForm({ user }: { user: User }) {
return (
<input onChange={(e) => deferredSaveUser(user, { name: e.target.value })} />
);
}// Also fine - scoped to one component instance via useRef/useMemo,
// if you specifically don't want to share it across the whole app.
function UserForm({ user }: { user: User }) {
const deferredSave = useMemo(() => createDeferred(saveUser, 300), []);
return (
<input
onChange={(e) => deferredSave(user, { name: e.target.value })}
/>
);
}A pending call also outlives the component that triggered it. The
setTimeout behind it is a plain runtime timer, completely outside React's
lifecycle - unmounting a component does not cancel it. If your .then()
touches component-local state, guard it, or better,
keep fn itself side-effect-only against an external store/API rather than a local setter:
useEffect(() => {
let isMounted = true;
deferredSaveUser(user, patch).then((res) => {
if (isMounted) setSavedState(res);
});
return () => {
isMounted = false;
};
}, [patch]);If instead you do want in-flight edits for one specific record dropped the
moment the user navigates away from it, pair this with
cancelPendingChangesForPivot() in
your cleanup:
useEffect(() => {
return () => {
cancelPendingChangesForPivot(user.id);
};
}, [user.id]);Behavior notes
- All calls in a window share one result. If three calls happen inside
the same window, all three promises resolve together, with the same value -
fngenuinely only runs once. - Merging is deep, via
deepmerge. Arrays and nested objects in your arguments get merged, not replaced - checkdeepmerge's docs if you need custom array-merge behavior. - A new call after the window has fired starts a fresh cycle - it does not merge with the previous (already completed) batch. The same is true after a cancel: a new call for that pivot starts a fresh cycle too, with no memory of the discarded arguments.
- Need to flush everything immediately instead of waiting out the debounce
window? See
applyAllPendingChanges()(every pivot) orapplyPendingChangesForPivot()(a single pivot). - Need to discard pending changes instead of running them? See
cancelPendingChangesForPivot(). - Works in both Node.js (≥18) and browsers.
License
MIT
