Some bugs arrive with a very clear message. This one showed up as a loading gif that simply refused to leave the screen.
The funny part was that the backend call had already succeeded.
The dashboard was doing a pull-to-refresh. The request went out, a 200 came back, the response payload looked fine, local state was being updated, and the logs were full of reassuring little breadcrumbs that normally tell you to move on with your life. But the UI stayed stuck in the refresh/loading state long enough to feel suspicious, and after a restart the crash log finally gave away what had been happening behind the scenes.
It was one of those errors that sounds obvious once you see it:
Bad state: Cannot use "ref" after the widget was disposed.
That sentence did more work than the earlier logs.
The screen was technically succeeding
What made this bug annoying was that the obvious suspects were innocent.
It wasn't a failed API call.
It wasn't a malformed response.
It wasn't even the usual "RefreshIndicator callback never completed" problem.
The refresh flow was actually finishing its network work. The broken part was what happened after the await.
In the dashboard package, the refresh handler looked roughly like this:
Future<void> refreshDashboard(WidgetRef ref) async {
await onRefresh();
ref.invalidate(dashboardDataProvider);
await ref.read(dashboardDataProvider.future);
}
That looks reasonable at first glance. Wait for the parent refresh, invalidate the provider, then wait for fresh data so the indicator can finish cleanly.
The catch was that onRefresh() did not just fetch data. It also triggered a loading mode in the parent screen.
And that loading mode mattered more than I initially gave it credit for.
Where things got interesting
The dashboard had two layers of state going on:
- an app-level dashboard fetch that updated domain entities after the network request
- a package-level provider that exposed dashboard data to the newer UI flow
That by itself is manageable. A little layered, but manageable.
The real issue was that the parent screen treated every dashboard fetch as if the app was starting from cold boot. So on pull-to-refresh, it set a loading flag that caused the entire dashboard body to be replaced with a full-page loader.
That means the refreshing child widget got disposed while its own async refresh callback was still in flight.
So the sequence was basically this:
- User pulls to refresh.
- Child widget starts
onRefresh().
- Parent sets a global loading state.
- Parent rebuilds and swaps the dashboard out for a big loading gif.
- Child widget is disposed.
- Await returns.
- Child tries to use
ref.invalidate(...).
- Riverpod says, very correctly, absolutely not.
That was the whole bug.
Not network.
Not parsing.
Not authentication.
Just a subtle disagreement between what refresh means and what initial loading means.
A loading state with too much authority
This is the part I keep running into in mobile apps: one boolean starts out innocent, then slowly becomes responsible for too many UI decisions.
isLoading sounds harmless until it controls both of these:
- whether the app has any usable data yet
- whether existing data should remain on screen during a background refresh
Those are not the same thing.
If the dashboard has never loaded before, a blocking full-screen loader is fine.
If the dashboard is already visible and the user pulls to refresh, blanking out the entire screen is usually the wrong move anyway. It feels heavier than necessary, it creates more rebuild churn, and in my case it introduced a lifecycle bug because the widget that initiated the refresh no longer existed by the time the callback resumed.
So the fix was less dramatic than the debugging session made it feel.
I changed the parent dashboard screen to only show the full-page loader when there was no existing dashboard data yet. If data was already present, refresh would keep the current UI mounted and let the RefreshIndicator own the loading experience.
The change looked more like this:
Future<void> loadDashboard() async {
final hasExistingData = ref.read(dashboardStateProvider).user != null;
if (!hasExistingData) {
setLoading(true);
}
final data = await repository.fetchDashboard();
updateDashboardState(data);
setLoading(false);
}
And in the build method:
final isLoading = ref.watch(dashboardStateProvider).isLoading;
final user = ref.watch(dashboardStateProvider).user;
if (isLoading && user == null) {
return const FullScreenLoader();
}
Small condition. Big difference.
Why this fix felt better than just adding guards
I could have stopped at a defensive patch inside the child refresh callback.
Something like converting the widget to a ConsumerStatefulWidget, checking mounted, and avoiding any provider work after disposal. That would have made the crash go away.
But it wouldn't have answered the more important question: why was the widget being disposed during a simple refresh in the first place?
That is the part that actually mattered.
When a screen already has usable data, I generally want refresh to be additive, not destructive. Keep the current content visible. Let the user stay oriented. Update in place. If the refresh fails, the user should still have the previous dashboard, not a blank page and an identity crisis.
So the architectural correction was better than a pure lifecycle bandage.
The mounted/disposal guard is still a good idea in general. But in this case, preserving the widget tree during refresh was the cleaner move because it aligned the UI behavior with what the user was actually doing.
The part Flutter didn't exactly hide, but also didn't volunteer
RefreshIndicator is very simple on paper: return a Future, and it spins until that future completes.
The trap is that the future can represent much more than a network call. It can include provider invalidation, storage reads, parent rebuilds, cross-package state synchronization, and whatever else your screen architecture has accumulated over time.
So when the spinner doesn't stop, the answer is not always "the API is hanging." Sometimes the future is completing into a widget tree that no longer exists.
That was today's reminder that successful logs can be misleading. I had a perfectly healthy response and still a broken interaction because the state transitions around the response were fighting each other.
What I'm keeping from this one
I left the session with a stronger preference for separating initial load, background refresh, and empty state as distinct UI situations, even if they all stem from the same repository method.
They deserve different behavior.
They often deserve different visuals.
And if they all collapse into one loading flag, the code will usually work right up until it doesn't.
Today it manifested as a stuck dashboard refresh and a Riverpod disposal error. On another screen it could just as easily show up as scroll position resets, flickering forms, or stateful children quietly losing context every time fresh data arrives.
Flutter didn't really do anything wrong here. Riverpod definitely didn't. The app was just expressing an ambiguous state model with too much confidence.
Anyway, the refresh spinner goes away now, which is honestly the kind of quiet victory I respect.