Flutter Riverpod Provider Not Updating UI After State Change: Fixes
You update state inside a Riverpod provider, check the logs, and the value is definitely changing — but the widget on screen doesn't move. No rebuild, no error. The UI just sits there showing stale data. This is one of the most frustrating Flutter debugging experiences because nothing is obviously broken.
In most cases the bug comes down to one of four root causes: calling ref.read instead of ref.watch, mutating an object in place instead of replacing it, an equality check that silently short-circuits the notification, or watching a different provider instance than the one you're changing. All of them are fixable once you know where to look.
What You'll Learn
- Why Riverpod decides whether to rebuild a widget after a state change
- How
ref.readvsref.watchaffects reactivity - Why mutating state in place silently breaks everything
- How equality and
copyWithinteract with rebuild decisions - How provider scope and overrides can cause you to watch the wrong instance
Prerequisites
This article assumes you're using Riverpod 2.x (the current stable generation that ships with riverpod_annotation and hooks-free Notifier/AsyncNotifier classes). The patterns also apply to the older StateNotifierProvider API. You should be comfortable with the basic ConsumerWidget/ConsumerStatefulWidget setup.
How Riverpod Tracks State Changes
Riverpod knows a widget needs to rebuild by comparing the old state to the new state using ==. When you call state = newValue inside a Notifier, Riverpod runs oldState == newState. If they're equal, no rebuild happens. If they differ, every listener registered with ref.watch gets notified and schedules a rebuild.
That single rule explains almost every "my UI isn't updating" bug. Either the widget isn't watching at all, or the state comparison returns true when it shouldn't, or the wrong provider instance is being watched. Keep that loop in mind as you read through the fixes below.
Using ref.read Instead of ref.watch
This is the most common mistake, especially for developers coming from the old Provider package. ref.read is a one-shot read — it grabs the current value and walks away. It doesn't register a listener, so when state changes, the widget is never notified.
Broken pattern:
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.read doesn't subscribe — widget will never rebuild
final count = ref.read(counterProvider);
return Text('$count');
}
}
Fixed:
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch subscribes — rebuilds whenever counterProvider changes
final count = ref.watch(counterProvider);
return Text('$count');
}
}
ref.read has a legitimate use: calling methods on a notifier from inside a callback or event handler where you explicitly don't want to watch. The rule of thumb is straightforward — use ref.watch inside build, use ref.read inside callbacks.
ElevatedButton(
onPressed: () {
// Correct: ref.read inside a callback, just calling a method
ref.read(counterProvider.notifier).increment();
},
child: const Text('Increment'),
)
Mutating State Directly Instead of Replacing It
This one bites almost every developer who switches from setState to Riverpod. If your state is a list or a custom object, modifying it in place means the old reference and the new reference are identical. Riverpod's equality check sees no change and skips the rebuild.
Broken — mutating a list in place:
class TodoNotifier extends Notifier<List<String>> {
@override
List<String> build() => [];
void add(String item) {
// BUG: you're mutating the existing list, not replacing it
state.add(item);
// Riverpod sees: oldState == newState (same object), no rebuild
}
}
Fixed — replace the list entirely:
class TodoNotifier extends Notifier<List<String>> {
@override
List<String> build() => [];
void add(String item) {
// Correct: a brand-new list, different reference
state = [...state, item];
}
}
The same problem surfaces with maps and custom objects. If you call a method that mutates the object's fields without creating a new instance, Riverpod never knows anything changed. Always reassign state to a new object or collection.
This pattern is similar to how React's useState setter requires immutable updates — if you've run into analogous bugs there, the principle behind React hooks skipping updates is the same underlying idea.
Equality Checks Blocking Rebuilds
Even when you do replace the state object, Dart's default == for custom classes compares by reference, not by value. If you implement == and hashCode on your state class (or use equatable, freezed, or data_class), two structurally identical objects will be considered equal — which is usually what you want, but can cause a missed rebuild when you set a field back to its previous value.
A trickier version: you call copyWith but pass the same value the field already held. Riverpod compares old and new, finds them equal, and skips the rebuild. This isn't a bug in Riverpod — it's preventing unnecessary renders — but it can look like a broken provider.
@freezed
class UserProfile with _$UserProfile {
const factory UserProfile({required String name, required int age}) = _UserProfile;
}
class ProfileNotifier extends Notifier<UserProfile> {
@override
UserProfile build() => const UserProfile(name: 'Alice', age: 30);
void setName(String name) {
// If name == state.name already, copyWith produces an equal object
// and no rebuild fires — this is correct behavior, not a bug
state = state.copyWith(name: name);
}
}
If you genuinely need a rebuild even when the value is logically identical (for example, to replay an animation), you can temporarily bypass equality by adding a nonce or incrementing a revision counter in your state class. That said, this scenario is rare — most of the time a skipped rebuild is correct.
Watching the Wrong Provider
This one is subtle. In Riverpod, a provider is a global definition, but when you override it inside a ProviderScope — or use family parameters — each override or parameter combination creates an independent instance. If your widget watches the unoverridden provider but your logic mutates an overridden one, you're watching a different bucket.
// Two separate provider instances — watching one, mutating the other
final messageProvider = StateProvider<String>((ref) => 'hello');
// Somewhere in the tree:
ProviderScope(
overrides: [
messageProvider.overrideWith((ref) => StateController('overridden')),
],
child: ...,
)
// Inside a deeply nested widget:
final msg = ref.watch(messageProvider); // which instance does this resolve to?
Always check whether the widget and the mutation are living inside the same ProviderScope. Use the Riverpod DevTools extension or add a temporary ref.listen to confirm which instance is actually notifying.
The family modifier has the same trap. itemProvider(42) and itemProvider(43) are different providers. Watching one and modifying the other results in silence.
Scope and Override Pitfalls
ProviderScope not wrapping the entire app
If ProviderScope wraps only part of the widget tree, providers initialized outside that scope won't be visible to widgets inside it the way you expect. The fix is to place a single ProviderScope at the very root of your app — wrapping MaterialApp or CupertinoApp — and use nested overrides only when you intentionally want a scoped instance.
StateProvider vs Notifier
StateProvider exposes a StateController. When you watch a StateProvider you should watch the provider directly, not the .notifier sub-provider, unless you actually want the controller rather than the value.
// Watching the value — rebuilds when the string changes
final label = ref.watch(labelProvider);
// Watching the controller — rebuilds when the controller object itself changes
// (almost never what you want for UI data binding)
final controller = ref.watch(labelProvider.notifier);
If you accidentally watch .notifier in your Text widget you'll display the controller object, and the widget won't rebuild when the underlying string changes.
Common Gotchas You'll Hit in Real Projects
Calling ref.watch inside a non-build method
Riverpod only registers subscriptions made during the synchronous build call. If you call ref.watch inside an async method, a Timer callback, or after an await, the subscription may not behave correctly. Collect all your ref.watch calls at the top of build, then pass the values into your async logic.
AsyncNotifier state not propagating
When you use AsyncNotifier, setting state = AsyncValue.data(newValue) should trigger a rebuild. A common mistake is setting state to the same AsyncValue.data wrapping a structurally equal object — the equality check applies to the wrapped value too. Ensure the inner value is a new instance if you're using Equatable or freezed.
Using ConsumerWidget but forgetting the WidgetRef parameter
If you extend ConsumerWidget but your build signature accidentally omits WidgetRef ref, Dart will complain at compile time. But if you're migrating from a regular StatelessWidget and copy-paste the body, you might call ref.watch on a stale ref captured in a closure. Always use the ref provided directly by the build method, not one captured from an enclosing scope.
Double-check your state model's immutability
If you're building large Flutter apps, you likely have complex state models. Keeping those models immutable is foundational — not just for Riverpod but for general widget rebuild correctness. The patterns in structuring large Flutter applications cover how to lay out your state layer so these bugs are less likely to appear in the first place.
FutureBuilder interactions
If you mix FutureBuilder and Riverpod providers in the same widget, rebuilds from FutureBuilder can re-create futures and obscure whether Riverpod is actually rebuilding the widget or not. If you're seeing strange rebuild behavior in that combination, the root causes of FutureBuilder rebuilding on every parent setState are worth reading before assuming the Riverpod provider is at fault.
Providers depending on other providers that didn't notify
If provider B reads provider A with ref.watch(aProvider), and A's state appears to change but A itself doesn't notify (because of the equality issue above), then B also won't recompute, and any widget watching B won't rebuild. Trace the chain upward — the root cause is usually a mutation-in-place or equality mismatch in the upstream provider.
Confirming state is actually changing
Before debugging Riverpod internals, verify the state assignment is reached at all. Add a temporary print statement inside your notifier's method, or attach a ref.listen on the provider inside a parent widget:
// Temporary diagnostic: drop this in your parent ConsumerWidget build
ref.listen<int>(counterProvider, (previous, next) {
print('counterProvider changed: $previous -> $next');
});
If you see the print fire but the UI still doesn't update, the problem is in the widget tree (wrong provider being watched, wrong scope). If the print never fires, the problem is that state is never actually being set.
A similar debugging approach applies in other reactive frameworks. The pattern of tracing whether a notification was emitted vs. whether the listener acted on it comes up in cases where Flutter's own setState misbehaves too, and the diagnostic steps translate well.
Wrapping Up
Most Riverpod UI-not-updating bugs reduce to one of four things: listening instead of watching, mutating instead of replacing, equality blocking a valid notification, or watching the wrong provider instance. Work through this checklist when you hit a frozen widget:
- Switch any
ref.readinsidebuildtoref.watch. Reserveref.readfor callbacks. - Replace state rather than mutating it. For lists, use spread syntax. For objects, use
copyWithor create a new instance. - Check your equality implementation. If you're using
freezedorEquatable, verify that the new state is structurally different from the old one. - Confirm you're watching the right provider instance. Double-check
familyarguments andProviderScopeoverrides. - Add a temporary
ref.listenat a parent widget to confirm whether the provider is notifying at all, then narrow down from there.
Once these patterns are second nature, Riverpod's reactive model becomes genuinely predictable. The equality-based notification system is a feature, not a limitation — it's what prevents waterfall rebuilds. Working with it rather than against it keeps your app both correct and fast.
Frequently Asked Questions
Why does my Riverpod Notifier state change but the widget never rebuilds?
The most common reason is using ref.read instead of ref.watch inside the build method. ref.read is a one-shot read with no subscription, so when state changes the widget is never notified. Replace ref.read with ref.watch inside build for any value you need to display.
How do I update a list in a Riverpod Notifier without the UI freezing?
Assign a completely new list to state rather than calling methods like add() or remove() on the existing one. For example, use state = [...state, newItem] instead of state.add(newItem). Mutating the existing list leaves the reference unchanged, so Riverpod's equality check finds no difference and skips the rebuild.
Can Riverpod's equality check prevent a valid UI update?
Yes, if your state class implements == (via freezed, Equatable, or a manual override) and the new state is structurally identical to the old one, Riverpod will skip the rebuild. This is intentional behavior, but it can surprise you when you set a field back to its previous value via copyWith.
What is the difference between ref.watch and ref.listen in Riverpod?
ref.watch subscribes to a provider and causes the widget's build method to re-run whenever the provider's state changes. ref.listen lets you react to changes in a callback without triggering a rebuild, which is useful for showing dialogs or snackbars in response to state transitions.
Why would a Riverpod provider update one widget but not another watching the same provider?
This usually means the two widgets are resolving different provider instances. Check whether any ProviderScope overrides or family parameters are involved — each override and each unique family argument creates an independent instance. Watching one instance while mutating another silently produces no rebuild.
📤 Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!