This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A write that failed used to be silent unless the one screen that made it happened to catch and render its own error — and most didn’t. You’d click, nothing would move, and there was nothing to tell you whether it saved. This is the write-up on closing that gap once, at the layer every write already flows through, and on building the notification it shows in my design system first so it looks the same wherever it appears.
Every data write on the site is a TanStack Query mutation. When one rejects, React Query hands the error to whoever called useMutation — and if that caller didn’t wire up an onError and render something, the failure just evaporated. It worked most of the time, so the missing branch was easy to never write. The result was the worst kind of bug: not a crash, not a wrong number, just a click that quietly did nothing.
I didn’t want to fix that mutation by mutation. Fifty callers each remembering to catch and render an error is fifty chances to forget. I wanted one place that couldn’t be skipped.
React Query’s MutationCache has its own onError that fires for every mutation on the client, before the per-call handler. So the whole feature is: give the app’s QueryClient a mutation cache whose onError raises a toast, and nothing can fail unseen.
new QueryClient({
mutationCache: new MutationCache({
onError: (error, _v, _c, mutation) =>
notifyMutationError(error, mutation.meta),
}),
// ...existing query defaults
})The handler shows the backend’s own message where there is one, so You already have an active season wallet reaches you word for word, and a plain Something went wrong. Please try again. where the error carries nothing useful.
meta: { silent: true } on the mutation, so nothing is reported twice.The obvious way to raise a toast is a useToast() hook. But the mutation-cache handler runs inside the QueryClient, outside the React tree — there’s no component, so there’s no hook to call. That shaped the design-system piece I built for this: a Toaster you mount once, driven by an imperative toast.error(msg) (and success/warning/info) you can call from anywhere, React or not.
Under it is a tiny module-level store — a list of live toasts and a set of listeners — that Toaster subscribes to with useSyncExternalStore. Calling toast.error pushes a record and notifies; the mounted region re-renders. A toast raised before the region has mounted is simply queued in the store and shown when it does.
The region portals into document.body, and there is no document during a server render. An earlier component on this site crashed a whole route’s prerender by reaching createPortal on the server, so I built the guard in from the start — Toaster returns null until it has a document, and its server snapshot is an empty list. There’s a test that renders it to static markup with document deleted and asserts it comes back empty.
The toasts stack in a labelled live region. An error announces assertively so a screen reader interrupts with it; everything else is polite. That matches the urgency to the tone rather than shouting every notification, and it means the failure you couldn’t see before is now both visible and spoken.