# Riverpod Simplified: Lessons Learned From 4 Years of Development

> Learn how to distinguish providers from notifiers in Riverpod and prevent the spider web effect with these battle-tested lessons.

URL: https://dinkomarinac.dev/blog/riverpod-simplified-lessons-learned-from-4-years-of-development/
Author: Dinko Marinac
Published: 2025-03-17
Tags: dart, flutter, riverpod

## 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:

```dart
// 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:

```dart
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:

```dart
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:

```dart
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:

```dart
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:

```dart
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**](https://twitter.com/dinkomarinac) and [**LinkedIn**](https://www.linkedin.com/in/dinko-marinac/).
