Riverpod Simplified: Lessons Learned From 4 Years of Development

Dinko MarinacDinko Marinac
5 min read

Introduction

In the Flutter ecosystem, state management is always a hot topic. Riverpod gets a lot of hate for having weird syntax, unclear usage rules, and a tendency to create a "spider web" of providers. The last point stems from the previous one: unclear usage.

After using Riverpod extensively for four years, I've developed a set of practical lessons that have significantly improved my development experience and simplified my application architecture, which I want to share with you in order to simplify using Riverpod once and for all.

Let’s get to it.

Understanding the Core Distinction: Providers vs. Notifiers

The first and perhaps most important principle is to clearly understand the distinction between providers and notifiers:

Providers are for dependency injection. They should be used to provide dependencies throughout your application, making your code more modular and testable. Providers create a clean way to access services, repositories, and other dependencies without tight coupling.

Notifiers are for state management. Classes like AsyncNotifier and Notifier, and other notifier variants are specifically designed to manage state. They encapsulate the logic for how the state changes over time and in response to events.

By respecting this distinction, your codebase becomes more organized and easier to reason about. Each component has a clear responsibility, preventing the "spaghetti code" that often plagues state management solutions.

The Critical Difference: ref.watch vs. ref.read

A common source of unexpected rebuilds and performance issues in Riverpod applications is the misuse of ref.watch and ref.read:

Use ref.watch only inside widgets. The watch method creates a subscription to a provider, causing rebuilds whenever the watched provider changes. This is exactly what you want in widgets, but can lead to cascading, hard-to-debug rebuilds elsewhere.

Use ref.read for dependency injection. When you just need to get a value once (like in a service or repository), use ref.read. This retrieves the current value without setting up a subscription, preventing silent rebuild chains.

For example:

// GOOD: In a widget, watching for changes
Widget build(BuildContext context, WidgetRef ref) {
  final user = ref.watch(userProvider);
  return Text(user.name);
}

// GOOD: In a service/repository, reading once
class AuthService {
  AuthService(this.ref);
  final Ref ref;

  Future<void> login() async {
    final apiClient = ref.read(apiClientProvider);
    await apiClient.login();
  }
}

Debugging the Spider Web: onDispose and Observer

When faced with the infamous "spider web" of rebuilding providers, two tools are invaluable:

Use onDispose to track the provider lifecycle. Adding logging in to the onDispose method can help you understand when providers are being disposed of, which can reveal unexpected behavior:

final myProvider = Provider((ref) {
  ref.onDispose(() {
    print('myProvider was disposed');
  });
  return MyService();
});

Leverage ProviderObserver for comprehensive debugging. By implementing a custom ProviderObserver, you can log all provider changes, creations, and disposals:

class LoggingObserver extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    print('''
{
  "provider": "${provider.name ?? provider.runtimeType}",
  "newValue": "$newValue"
}
''');
  }
}

// Then in your app:
void main() {
  runApp(
    ProviderScope(
      observers: [LoggingObserver()],
      child: MyApp(),
    ),
  );
}

These tools provide visibility into Riverpod's internal workings, making it much easier to identify the source of unexpected rebuilds.

Useful Exceptions to the Rules

While the principles above serve as a solid foundation, there are exceptions worth noting:

StateNotifier: Should Be Deprecated, But Still Useful

StateNotifier should technically be deprecated in favor of the newer Notifier class. However, it remains extremely useful in certain scenarios due to its simplicity and clear contract:

final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() => state = state + 1;
}

I recommend swapping StateNotifier with Notifier for most use cases:

final counterProvider = NotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

class CounterNotifier extends Notifier<int> {
  @override
  int build() {
    return 0;
  }

  void increment() {
    state = state + 1;
  }
}

The newer notifiers provide better integration with other Riverpod features and a more consistent API. However, don't be afraid to use StateNotifier when its simplicity serves your specific case better - pragmatism should win over strict adherence to the latest patterns.

Future/Stream Providers vs. AsyncNotifier

FutureProvider and StreamProvider can be very useful for handling asynchronous data in simple scenarios. They automatically handle loading and error states:

final userProvider = FutureProvider.autoDispose((ref) async {
  final userId = ref.watch(userIdProvider);
  return await ref.watch(userRepositoryProvider).fetchUser(userId);
});

While FutureProvider and StreamProvider are convenient for quick implementations, AsyncNotifier scales better as your application inevitably grows in complexity.

Conclusion

Riverpod's power comes with complexity, but these lessons learned from experience can help you tame that complexity:

  1. Respect the distinction between providers (for DI) and notifiers (for state)

  2. Use ref.watch in widgets, ref.read everywhere else

  3. Leverage onDispose and ProviderObserver for debugging

  4. Prefer AsyncNotifier over FutureProvider/StreamProvider for better codebase consistency

  5. Consider using Notifier over StateNotifier, but recognize when the simplicity of StateNotifier is valuable

With these guidelines in place, Riverpod becomes less of a "spider web" and more of a precisely crafted tool that simplifies your Flutter application's architecture rather than complicating it.

Remember that state management is inherently complex - Riverpod doesn't create this complexity but rather provides mechanisms to manage it effectively. By being intentional about how and when you use these mechanisms, you can build Flutter applications that are both powerful and maintainable.

If you have found this useful, make sure to like and follow for more content like this. To know when the new articles are coming out, follow me on Twitter and LinkedIn.

0
Subscribe to my newsletter

Read articles from Dinko Marinac directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dinko Marinac
Dinko Marinac

Mobile app developer and consultant. CEO @ MOBILAPP Solutions. Passionate about the Dart & Flutter ecosystem.