Fixing Flutter Riverpod State Not Updating Across Multiple Providers

June 03, 2026 8 min read 35 views
Abstract diagram of interconnected nodes and arrows illustrating a reactive state dependency graph in soft blue tones

You trigger an action in one provider, but the widgets listening to a second provider stay completely frozen. You add print statements, the state is clearly changing, yet the UI refuses to rebuild. This is one of the most frustrating bugs in Flutter Riverpod β€” and it almost always comes down to a handful of predictable mistakes.

What you'll learn

  • Why providers silently stop propagating updates to dependents
  • The difference between watch, read, and listen β€” and when each breaks things
  • How provider scoping and ProviderScope overrides cause stale state
  • Using ref.invalidate and ref.invalidateSelf to force refreshes
  • Patterns for sharing state cleanly between multiple interdependent providers

Prerequisites

This article assumes you are using Riverpod 2.x (the hooks or annotations flavor both apply). You should already know how to define a basic StateNotifierProvider or NotifierProvider and wire it into a widget with ConsumerWidget. If you are still on Riverpod 1.x, most of this still applies, but some method names differ.

How Riverpod Propagates Updates

Before fixing anything, it helps to have a clear mental model. When you call ref.watch(someProvider) inside a provider or widget, Riverpod records a dependency edge. Any time the watched provider emits a new state, Riverpod walks that dependency graph and marks dependents as stale, which triggers rebuilds or recomputations in the right order.

The graph only works if you actually use watch at the right call site. Use read instead, and Riverpod does not register the dependency. The value is read once and never tracked. That single decision is responsible for the majority of "state not updating" reports.

Mistake 1: Using ref.read Where You Need ref.watch

This is the most common culprit. Inside a provider's build method or a widget's build method, ref.read grabs the current value without creating a subscription.

// BAD β€” cartProvider never reacts when userProvider changes
final cartProvider = Provider<Cart>((ref) {
  final user = ref.read(userProvider); // no subscription created
  return Cart(userId: user.id);
});
// GOOD β€” cartProvider rebuilds whenever userProvider emits a new value
final cartProvider = Provider<Cart>((ref) {
  final user = ref.watch(userProvider); // dependency registered
  return Cart(userId: user.id);
});

Reserve ref.read for callbacks and event handlers β€” places that run outside the build phase, such as button onPressed or an async function body. Never use it in a provider's synchronous build body if you need reactivity.

Mistake 2: Calling ref.watch Inside an Async Body

Riverpod tracks dependencies during the synchronous execution of a provider's build method. If you call ref.watch after an await, Riverpod has already finished recording dependencies for that build cycle and the watch call is silently ignored.

// BAD β€” watch after await is not tracked
final orderProvider = FutureProvider<List<Order>>((ref) async {
  final results = await fetchOrders();
  final user = ref.watch(userProvider); // called after await β€” ignored
  return results.where((o) => o.userId == user.id).toList();
});
// GOOD β€” watch before the first await
final orderProvider = FutureProvider<List<Order>>((ref) async {
  final user = ref.watch(userProvider); // tracked correctly
  final results = await fetchOrders();
  return results.where((o) => o.userId == user.id).toList();
});

As a rule: collect all your ref.watch calls at the top of the build method, before any async work begins.

Mistake 3: Broken Equality Checks Swallowing Updates

Riverpod only notifies listeners when the new state is different from the previous state. It uses == for comparison by default. If your state class does not override == and hashCode β€” or uses an identity-based comparison β€” you can end up in two opposite failure modes.

  • Too many rebuilds: A new object is created on every update even when the data is logically identical, because two separate instances are never == by default.
  • No rebuilds: You mutate a field on an existing object in place instead of returning a new instance. The reference stays the same, so == is true and Riverpod skips the notification entirely.
// BAD β€” mutating state in place, Riverpod sees no change
class CartNotifier extends Notifier<Cart> {
  @override
  Cart build() => Cart(items: []);

  void addItem(Item item) {
    state.items.add(item); // mutating the existing list β€” state reference unchanged
  }
}
// GOOD β€” return a new Cart instance
void addItem(Item item) {
  state = Cart(items: [...state.items, item]); // new object, Riverpod detects the change
}

If you use freezed for your state classes, == is generated for you and value equality is handled correctly. That is one of the main reasons teams reach for it.

Mistake 4: Provider Scoping Hiding the Wrong Instance

ProviderScope lets you override providers for a subtree of the widget tree. This is powerful for testing and feature isolation, but it means a nested ProviderScope can silently provide a completely different instance of a provider than the one your parent widget tree is watching.

// A nested ProviderScope creates an isolated instance of cartProvider
ProviderScope(
  overrides: [
    cartProvider.overrideWith((ref) => Cart(items: []))
  ],
  child: CheckoutScreen(), // watches a DIFFERENT cartProvider instance than the rest of the app
);

If CheckoutScreen updates the scoped cart but another part of your app watches the root-level cart, neither will see the other's changes. Check your widget tree for accidental nested ProviderScope wrappers when state seems compartmentalized unexpectedly.

Mistake 5: Misusing autoDispose With Long-Lived State

A provider marked autoDispose is destroyed when no widget is actively watching it. If you navigate away briefly β€” say, to a dialog or a loading overlay β€” and all listeners detach, the provider resets to its initial state. When you come back, you get fresh state, which looks like the previous update was lost.

// This provider resets every time the last listener disappears
final sessionProvider = StateNotifierProvider.autoDispose<SessionNotifier, Session>(
  (ref) => SessionNotifier(),
);

If the state needs to survive navigation events, drop autoDispose. If you want it to persist but also clean up eventually, use ref.keepAlive() inside the provider to hold a reference manually until you are ready to release it.

final sessionProvider = NotifierProvider.autoDispose<SessionNotifier, Session>(() {
  return SessionNotifier();
});

class SessionNotifier extends AutoDisposeNotifier<Session> {
  @override
  Session build() {
    ref.keepAlive(); // prevents disposal even when no listeners are attached
    return Session.initial();
  }
}

Mistake 6: Not Invalidating When You Need a Fresh Computation

Sometimes a provider depends on external state that Riverpod cannot observe automatically β€” an in-memory cache, a singleton service, or a platform channel. When that external state changes, the provider has no way to know it should recompute.

In these cases, call ref.invalidate(someProvider) from a notifier or widget to force Riverpod to discard the cached value and rerun the build function on next access.

// Force the profileProvider to recompute after an account update
future void updateAccount(WidgetRef ref, AccountData data) async {
  await accountService.save(data);
  ref.invalidate(profileProvider); // next watch will trigger a fresh build
}

From inside a provider itself, use ref.invalidateSelf() to schedule a rebuild on the next frame without needing an outside reference.

Mistake 7: family Modifier Parameter Mismatch

When you use the .family modifier, each unique parameter value creates a separate provider instance. If one part of your app passes userId: 42 and another part passes userId: "42" (a string), they are watching completely different instances and will never share state.

final userDetailProvider = FutureProvider.family<UserDetail, int>(
  (ref, userId) => api.fetchUser(userId),
);

// These two watch calls observe DIFFERENT providers
ref.watch(userDetailProvider(42));   // int
ref.watch(userDetailProvider("42")); // won't even compile with strong typing, but
                                     // subtler mismatches (e.g. int vs double) can slip through

Also make sure your parameter type overrides == and hashCode if it is a custom class. Riverpod uses those to look up the cached instance, and two objects that are logically equal but have different identities will each create a fresh provider.

Common Pitfalls Checklist

  • Using ref.read inside a provider's build body
  • Calling ref.watch after an await inside a FutureProvider
  • Mutating state objects in place rather than assigning a new instance
  • Accidental nested ProviderScope creating isolated provider instances
  • autoDispose discarding state during brief navigation events
  • Skipping ref.invalidate when external state changes outside Riverpod's graph
  • Mismatched family parameter types or missing ==/hashCode on custom parameter objects

Debugging Strategy

When you hit a stale-state bug, work through these steps in order before touching any code.

  1. Add a ref.listen directly on the suspect provider and print every state change. If you see changes in the log but not in the UI, the problem is in the widget layer. If you see nothing, the problem is upstream in the provider graph.
  2. Use Riverpod's ProviderObserver to log every provider update application-wide. Attach it to your root ProviderScope during debugging and scan the output for the providers that are and are not firing.
  3. Check the dependency chain by tracing each ref.watch from the widget back through all intermediate providers to the source of truth.
  4. Verify object identity by printing state.hashCode before and after a mutation. If the hash does not change, you are mutating in place.
// Attach a ProviderObserver for debugging
class StateLogger extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    print('[${provider.name ?? provider.runtimeType}] $previousValue -> $newValue');
  }
}

void main() {
  runApp(
    ProviderScope(
      observers: [StateLogger()],
      child: MyApp(),
    ),
  );
}

Wrapping Up

Most Riverpod state propagation bugs fall into a short list of root causes. Here are the concrete actions to take right now:

  1. Audit every provider build body for ref.read calls and replace them with ref.watch wherever reactivity is needed.
  2. Move all ref.watch calls to the top of any FutureProvider or StreamProvider body, before the first await.
  3. Switch to immutable state β€” use copyWith patterns or the freezed package so every state change produces a new object with a new identity.
  4. Add a ProviderObserver in debug mode so you always have a log of which providers are and are not updating.
  5. Review your widget tree for nested ProviderScope blocks that may be unintentionally isolating providers from the rest of the app.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.