T
TanStack•2mo ago
rare-sapphire

How to setup debouncing correctly?

I've seen this feature proposal for Serialized Transaction Queuing Utility but was wondering if you had any recommendation to set debounced updates properly in the mean time? I tried writing a wrapper over onUpdate that associate an async debouncer from Tanstack Pacer with the onUpdate call using the mutation key (I only care for the first mutation in the transaction), but it kinda breaks the "reconciliation" with previous refetches. My use cases are inputs like text boxes, sliders, are really any kind of input that needs optimistic updates with a debounced persistence.
12 Replies
rare-sapphire
rare-sapphireOP•2mo ago
ok I think I need to reach for Manual Transactions and implement the proposed useSerializedTransaction! If you have any tip or caveats in mind, I'm all ears 🙂
flat-fuchsia
flat-fuchsia•2mo ago
Yeah this only works with manual transactions as then you can delay committing changes It might just work! I'd try feeding the proposal to Claude Code and just seeing what it comes up with It should be a new package
rare-sapphire
rare-sapphireOP•2mo ago
alright! This is what I've come up with, and it actually solves two problems: - debouncing transactions - sharing the returned commit fn globally with an optional id (very useful for composability) I've decided to implement both at the same place, but I'll probably split the 2 features into separate hooks.
type DebouncedTransactionConfig<T extends object = Record<string, unknown>> =
Omit<TransactionConfig<T>, "autoCommit"> & {
waitMs?: number;
id?: SharedTransactionsId;
};
type DebouncedTransactionConfig<T extends object = Record<string, unknown>> =
Omit<TransactionConfig<T>, "autoCommit"> & {
waitMs?: number;
id?: SharedTransactionsId;
};
export const useDebouncedTransaction = <
T extends object = Record<string, unknown>,
>(
props: DebouncedTransactionConfig<T>
) => {
const reactId = useId();
const id = props.id ?? reactId;

return useCallback(
(mutationFn: () => void) => {
let transaction: Transaction<T> | undefined;

const existing = TransactionsMap.get(id);
if (existing) {
clearTimeout(existing.timer);
transaction = existing.transaction;
} else {
transaction = createTransaction({
mutationFn: props.mutationFn,
metadata: props.metadata,
autoCommit: false,
});
}

transaction.mutate(mutationFn);

const timer = setTimeout(() => {
try {
transaction?.commit();
} finally {
// allways cleanup the transaction for the shared id
TransactionsMap.delete(id);
}
}, props.waitMs ?? 500);

TransactionsMap.set(id, { timer, transaction });

return transaction;
},
[id, props]
);
};

type SharedTransactionsId = string | number;
const TransactionsMap = new Map<
SharedTransactionsId,
// biome-ignore lint/suspicious/noExplicitAny: we need to accept all kinds of transactions stricter than default
{ timer: ReturnType<typeof setTimeout>; transaction: Transaction<any> }
>();
export const useDebouncedTransaction = <
T extends object = Record<string, unknown>,
>(
props: DebouncedTransactionConfig<T>
) => {
const reactId = useId();
const id = props.id ?? reactId;

return useCallback(
(mutationFn: () => void) => {
let transaction: Transaction<T> | undefined;

const existing = TransactionsMap.get(id);
if (existing) {
clearTimeout(existing.timer);
transaction = existing.transaction;
} else {
transaction = createTransaction({
mutationFn: props.mutationFn,
metadata: props.metadata,
autoCommit: false,
});
}

transaction.mutate(mutationFn);

const timer = setTimeout(() => {
try {
transaction?.commit();
} finally {
// allways cleanup the transaction for the shared id
TransactionsMap.delete(id);
}
}, props.waitMs ?? 500);

TransactionsMap.set(id, { timer, transaction });

return transaction;
},
[id, props]
);
};

type SharedTransactionsId = string | number;
const TransactionsMap = new Map<
SharedTransactionsId,
// biome-ignore lint/suspicious/noExplicitAny: we need to accept all kinds of transactions stricter than default
{ timer: ReturnType<typeof setTimeout>; transaction: Transaction<any> }
>();
i'll send the updated version that only cares for the debouncing so the steps to debounce transactions (ignoring the shared transaction thing), in case it could benefit anyone: - create a transaction with a stable reference (could be in a ref, or outside react) - create a place to store the timer (can be in a ref too, or outside react) - return a commit function that takes the mutationFn - inside the commit function, first check if your timer exist - if it does not exist: create a timer that will fire the transaction commit and initialize a fresh new transaction - if the timer exists: cancel it, and do the same as above - using the fresh or uncommited transaction, call the mutationFn using transaction.mutate(mutationFn) - make sure the timer and the transaction are up to date if you just created new ones - return the transaction
flat-fuchsia
flat-fuchsia•2mo ago
nice work! You want to put your code above + explaination into the issue? That's a nice simple example people can use to make their own utilities
rare-sapphire
rare-sapphireOP•2mo ago
yes once I finished refactoring it I’ll definitely post it here (probably today) I was looking for an excuse to write a my first blog post about tanstack db, now I know the topic I’ll share! I think a blog post explaining how to implement the features of the proposal could be a great read, and also a way to introduce the power of manual transactions
flat-fuchsia
flat-fuchsia•2mo ago
oh nice! You've been playing with it a lot? Or building something new? yeah manual transactions started as the default and then people (rightly) suggested hiding them away a bit for the common use cases — but they're quite interesting/useful
rare-sapphire
rare-sapphireOP•2mo ago
I refactored our frontend entirely around tanstack db and still learning about it along the way! But I feel like a good way to keep learning about DB would be to try to actually explain how it works, and I believe the proposal is approchable enough for my current understanding
flat-fuchsia
flat-fuchsia•2mo ago
woah awesome! Yeah, explaining something is a good reason/incentive to dig deeper how'd the refactor go? Anything missing in docs/apis you wished existed?
rare-sapphire
rare-sapphireOP•2mo ago
I can’t wait for it to support ssr. But I’m fine with fetching on the client only for now. The error on the server was quite frustrating, so I patched react-db so that I didn’t have to turn ssr off I’m having trouble with errors thrown by the promise in transaction.isPersisted, as it seems like it’s throwing as many errors as the number of times transaction.mutate() got called. I’d expect it to throw only once when the mutationFn throws or a the transaction is rollbacked. Also I made a custom hook I wish was available from react-db to know if a collection has pending transactions. I use it inside tanstack router's useBlocker oops didn’t send this as a response
flat-fuchsia
flat-fuchsia•2mo ago
yeah ssr is a pain — we'll get to it but it's a big chunk of work. Hmm the errors are very unexpected — what are they? Cause yeah, that should only reject if you throw an error in the mutationFn That hook does sound nice! What's the code?
rare-sapphire
rare-sapphireOP•2mo ago
alright I think it's working fine, haven't tested it much but it's much more elegant than my previous implementation (and it actually works):
import {
createTransaction,
type Transaction,
type TransactionConfig,
} from "@tanstack/react-db";
import { useCallback, useRef } from "react";

export const useDebouncedTransaction = <
T extends object = Record<string, unknown>,
>(
props: DebouncedTransactionConfig<T>
): DebouncedTransactionCommit<T> => {
const timer = useRef<Timer>(undefined);
const transaction = useRef<Transaction<T>>(
createTransaction({ ...props, autoCommit: false })
);

return useCallback(
(mutationFn: () => void) => {
if (timer.current) clearTimeout(timer.current);

transaction.current.mutate(mutationFn);

timer.current = setTimeout(() => {
transaction.current.commit();
timer.current = undefined;
transaction.current = createTransaction({
...props,
autoCommit: false,
});
}, props.waitMs ?? 500);

return transaction.current;
},
[props]
);
};

type Timer = ReturnType<typeof setTimeout> | undefined;

export type DebouncedTransactionConfig<
T extends object = Record<string, unknown>,
> = Omit<TransactionConfig<T>, "autoCommit"> & {
waitMs?: number;
};

export type DebouncedTransactionCommit<
T extends object = Record<string, unknown>,
> = (mutationFn: () => void) => Transaction<T>;
import {
createTransaction,
type Transaction,
type TransactionConfig,
} from "@tanstack/react-db";
import { useCallback, useRef } from "react";

export const useDebouncedTransaction = <
T extends object = Record<string, unknown>,
>(
props: DebouncedTransactionConfig<T>
): DebouncedTransactionCommit<T> => {
const timer = useRef<Timer>(undefined);
const transaction = useRef<Transaction<T>>(
createTransaction({ ...props, autoCommit: false })
);

return useCallback(
(mutationFn: () => void) => {
if (timer.current) clearTimeout(timer.current);

transaction.current.mutate(mutationFn);

timer.current = setTimeout(() => {
transaction.current.commit();
timer.current = undefined;
transaction.current = createTransaction({
...props,
autoCommit: false,
});
}, props.waitMs ?? 500);

return transaction.current;
},
[props]
);
};

type Timer = ReturnType<typeof setTimeout> | undefined;

export type DebouncedTransactionConfig<
T extends object = Record<string, unknown>,
> = Omit<TransactionConfig<T>, "autoCommit"> & {
waitMs?: number;
};

export type DebouncedTransactionCommit<
T extends object = Record<string, unknown>,
> = (mutationFn: () => void) => Transaction<T>;
and bonus hook: I find it really useful to have this globally shared map for components that need to share a transaction:
type SharedTransactionsId = string | number;
const TransactionsMap = new Map<
SharedTransactionsId,
// biome-ignore lint/suspicious/noExplicitAny: can't infer the transaction from here, tad unsafe but it's fine
DebouncedTransactionCommit<any>
>();

const useSharedDebouncedTransaction = <
// biome-ignore lint/suspicious/noExplicitAny: shortcut, seems type safe
C extends Collection<any, any, any, any, any> = Collection,
D extends object = CollectionItem<C>,
>(
id: SharedTransactionsId,
debouncedProps: DebouncedTransactionConfig<D>
) => {
const fallback = useDebouncedTransaction<D>(debouncedProps);

return useCallback(
(mutationFn) => {
let commit = TransactionsMap.get(id);
if (!commit) {
commit = fallback;
TransactionsMap.set(id, fallback);
}

const result = commit(mutationFn);
result.isPersisted.promise.finally(() => {
TransactionsMap.delete(id);
});

return result;
},
[id, fallback]
) satisfies DebouncedTransactionCommit<D>;
};
type SharedTransactionsId = string | number;
const TransactionsMap = new Map<
SharedTransactionsId,
// biome-ignore lint/suspicious/noExplicitAny: can't infer the transaction from here, tad unsafe but it's fine
DebouncedTransactionCommit<any>
>();

const useSharedDebouncedTransaction = <
// biome-ignore lint/suspicious/noExplicitAny: shortcut, seems type safe
C extends Collection<any, any, any, any, any> = Collection,
D extends object = CollectionItem<C>,
>(
id: SharedTransactionsId,
debouncedProps: DebouncedTransactionConfig<D>
) => {
const fallback = useDebouncedTransaction<D>(debouncedProps);

return useCallback(
(mutationFn) => {
let commit = TransactionsMap.get(id);
if (!commit) {
commit = fallback;
TransactionsMap.set(id, fallback);
}

const result = commit(mutationFn);
result.isPersisted.promise.finally(() => {
TransactionsMap.delete(id);
});

return result;
},
[id, fallback]
) satisfies DebouncedTransactionCommit<D>;
};
the TransactionsMap should probably be renamed to something else, as it does not hold any transaction but you get the idea Also I love how I can just free memory when the transaction has finished persisting
result.isPersisted.promise.finally(() => {
TransactionsMap.delete(id);
});
result.isPersisted.promise.finally(() => {
TransactionsMap.delete(id);
});
without having to pass callbacks or handle my own promises tx.isPersisted.promise is such a useful thing to have
flat-fuchsia
flat-fuchsia•2mo ago
🔥 nice stuff!

Did you find this page helpful?