Reset Riverpod state on logout: rebuild ProviderScope
Reset Riverpod state on logout by keying ProviderScope with a counter, so no provider keeps the last user's data. Plus the toast and site switch bugs it caused.
By Hamza Ghouri
If you need to reset Riverpod state on logout, the cheapest correct answer is to throw the whole ProviderScope away and mount a new one. Providers cache what they last fetched, and nothing in Riverpod clears that cache because a user signed out. On a B2B operations app I work on, that meant the next person to sign in could see the previous user's lists. This post covers the fix I shipped: one widget above the scope, one integer key, and a listener that only fires on a real logout. It also covers the two toast bugs the fix caused, and how the same reset handles switching between sites.
Why you need to reset Riverpod state on logout
Riverpod providers do not hold state themselves. The Riverpod docs on containers and scopes explain that state lives in a ProviderContainer, and ProviderScope is the widget that creates one for your app. As long as that scope is mounted, every provider that has been read keeps its value.
On this app the cached values are the things a user works with all day: jobs, records, sites, team members. Signing out cleared the tokens, but those providers stayed alive. When someone signed back in as a different user, the screens showed the previous user's data for a moment until the new fetches landed.
Sometimes it was longer than a moment. If the new user's role could not refetch a particular list, nothing ever replaced the old value, so the stale data stayed on screen. That is a correctness bug and a privacy bug at once, which is why I wanted a fix that could not miss a provider.
How do I reset all Riverpod providers on logout?
Wrap ProviderScope in a StatefulWidget that holds an integer, key the scope with ValueKey(thatInteger), and increment it on logout. Flutter sees a widget with a new key, disposes the old scope and every provider inside it, and mounts a fresh one. The app then behaves exactly as it does on a cold start.
Key the ProviderScope with an epoch
The mechanism is plain Flutter. The Widget.key documentation says that when a widget's key changes, the old element is removed from the tree and a new one is inflated in its place. For a ProviderScope, removing the element disposes its container, and with it every provider's state.
Here is the shape of the gate, trimmed and renamed:
class SessionScope extends StatefulWidget {
const SessionScope({super.key, required this.child});
final Widget child;
@override
State<SessionScope> createState() => _SessionScopeState();
}
class _SessionScopeState extends State<SessionScope> {
int _epoch = 0;
void _reset() {
if (!mounted) return;
// Toasts live in the overlay this reset throws away.
toastification.dismissAll(delayForAnimation: false);
setState(() => _epoch += 1);
}
@override
Widget build(BuildContext context) {
return ProviderScope(
key: ValueKey<int>(_epoch),
child: _LogoutListener(onSignedOut: _reset, child: widget.child),
);
}
}
SessionScope replaces the bare ProviderScope at the top of runApp, with the whole app, router included, as its child.
The piece that makes this safe is where the tokens live. They are in flutter_secure_storage, outside the scope. When the new scope starts, its auth bootstrap reads storage, finds it empty because logout cleared it, and lands on the sign-in screen. Nothing needs a special "after logout" code path. The reset and a cold start run the same code.
I picked this over the alternatives because it scales with zero effort. Calling ref.invalidate on each provider at logout works until someone adds a new provider and forgets to add it to the list. Keying the scope covers providers that do not exist yet.
Only react to a real logout
The listener that triggers the reset sits inside the scope, so it can watch the auth provider:
class _LogoutListener extends ConsumerWidget {
const _LogoutListener({required this.onSignedOut, required this.child});
final VoidCallback onSignedOut;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<AuthState>(authProvider, (prev, next) {
if (prev is SignedIn && next is SignedOut) {
WidgetsBinding.instance.addPostFrameCallback((_) => onSignedOut());
}
});
return child;
}
}
Two details in there are deliberate.
The condition is strict. It fires only when the previous state was signed in and the next is signed out. On a fresh install, the auth state goes from bootstrapping to signed out, and that must not count. If it did, the new scope would bootstrap, emit signed out, trigger another reset, and the app would loop on first launch.
The reset waits a frame. The listener runs while the auth view model is still emitting its new state. Tearing down the scope that contains it in the middle of that is asking for trouble. addPostFrameCallback lets the emit finish, then resets on the next frame.
Switching sites gets the same reset
Later the app gained multi-site support: one user can belong to several sites and switch between them. Every cached list is scoped to the active site, so after a switch all of them are stale. That is the same problem as logout, so it got the same reset.
The check is narrower, though. It requires the same user id on both sides and a different site id. The fresh scope's bootstrap starts from a bootstrapping state, so it can never match "same user, signed in before and after", which keeps it from triggering a second reset.
Order matters here too. The site switch writes the updated user to storage first, then resets. The rebuilt scope's cached fallback already carries the new site, so the first frame after the reset is already on the right site. I wrote more about tenant and site boundaries in multi-tenant SaaS architecture.
The toast bugs a scope reset causes
Throwing away the scope throws away everything under it, including the navigator and its overlay. That caught me twice.
Toasts fired during a reset vanished. After a site switch the app shows "Switched to" and the site name. That toast was raised just before the reset, rendered into the overlay, and disappeared with it a frame later. The fix was to hold the message and show it once the rebuilt scope has signed in again.
The toast package then swallowed new toasts. The toast package kept its entry pointing at the dead overlay, and new toasts queued behind it until the stale one timed out. The fix is the toastification.dismissAll call in _reset above: clear every live toast before each reset, so nothing points at an overlay that no longer exists.
The general lesson: anything that renders through the navigator, such as toasts, dialogs and snackbars, needs a plan for what happens when the scope goes away under it.
Carrying values across the reset
Some values must survive a reset: an active demo persona, a route to open after the rebuild, a notice to show once the new screen is up. Putting them in providers would defeat the point, since providers are exactly what gets wiped.
They live in the gate's State instead, above the scope, and go back in through provider overrides on the new ProviderScope. The new scope reads them like any other provider, and they are never written anywhere persistent unless that is what you want.
Widget tests cover the reset paths and the stale toast case. A change like this touches every screen at once, so I wanted it pinned down by tests before it shipped. If you test Flutter apps by driving the real UI, I covered one way in letting an AI agent test your Flutter app, and other Flutter lessons are in keeping test API keys out of production builds.
Where I'd start
- Move tokens and anything that must outlive a session into storage outside the
ProviderScope. - Wrap the scope in a
StatefulWidgetwith an epoch, and key the scope withValueKey(epoch). - Trigger the reset only on signed in to signed out, deferred to the next frame.
- Dismiss toasts before each reset and hold any message you want shown until the new scope is ready.
- Reuse the same reset for anything that makes every cached list stale, such as switching sites or accounts.
- Pin the reset paths down with widget tests.
The multi-site work behind the site switch reset started as a written spec, the way I describe in spec-driven development. More of my Flutter and backend work is on my projects page.