Using TanStack Query and Sonner’stoast.promise() for better UX.
Combining TanStack Query’s mutation state with Sonner’s toast.promise()
Toggle the checkbox to compare toast.promise() vs toast.success() / toast.error() after the request.
The Silent Signup Problem
You fill out a registration form. You click “Sign Up”… the button does something? Maybe it goes grey. Maybe a spinner appears. You wait. Did it work? Is it still loading? Should you click again?
This is basically the default experience on most web apps — and it's entirely avoidable. The issue isn't the backend. The request went through. The request was done, in this case, your account was created. But the user never felt it. That disconnect between system state and user perception is where trust breaks down. Something missing in the User Experience (UX).
Two Libraries, One Pattern
TanStack Query and Sonner solve different problems that, together, close this gap completely.
TanStack Query manages server state —> it tracks whether a mutation is pending, successful, or failed, and gives you reactive values to build your UI around.
Sonner (via toast.promise) handles notification-level feedback —> it watches a promise and automatically renders the right toast at each lifecycle stage.
Used together, they give you both local feedback (button states, inline spinners) and global feedback (persistent toast notifications) from a single mutation call.
The Signup Flow
Using the most common case, Sign/Register, let’s implement this pattern
Okay, let’s break it down 💃🏽… Sorry I’ll stop dancing.
How useRegisterUser Sets the Foundation
The useRegisterUser hook is a thin wrapper around useMutation from TanStack Query. It returns two things we care about:
mutateAsync — a function that triggers the mutation and returns a promise. This is critical because toast.promise() needs a promise to watch.
isPending — a boolean that's true while the request is in flight.
🗣️ DO NOT USE mutate The distinction between mutate and mutateAsync matters here. mutate is fire-and-forget — it doesn't return a promise, so you can't pass it to toast.promise(). mutateAsync does, which is what makes this pattern possible.
How toast.promise Takes Over
Sonner's toast.promise() accepts two arguments: a promise and a configuration object mapping three states to user-facing messages.
The moment the promise registerUser() fires, a toast appears with “Creating your account…” — the user immediately knows their submission was received and the system is working. When the promise resolves, the same toast morphs into the success message. When it rejects, it morphs into the error. One toast, three states, zero manual state management
What Good Feedback Looks Like
State
Weak
Strong
Loading
"Loading..."
"Creating your account"
Success
"Done!"
"Registration successful! Redirecting..."
Error
"Error"
"Registration failed — email already in use"
The difference is specificity. “Loading” describes the computer's state. “Creating your account” describes the user's journey. Strong feedback is always written from the user's perspective, not the system's.
The Pattern Generalized
This isn't specific to signup. Any mutation — login, file upload, payment, profile update — follows the same shape:
Once you internalize this, you'll notice every form in your app that doesn't follow it. And each one is a place where users are left guessing.
Wrapping Up
The combination of TanStack Query and toast.promise() gives you a declarative, minimal-code pattern for something that has an outsized impact on how your product feels. No manual loading states. No forgotten error handlers. No silent successes.
The user clicks a button. They're told what's happening. They're told when it's done. They're told when it fails and why. That's the entire UX improvement. And it takes about ten lines of code, give or take.