Riverpod in Modern Flutter: State Management Guide
State management is one of the most discussed topics in Flutter development, and Riverpod has emerged as a powerful, flexible solution for building modern Flutter applications. Unlike traditional approaches that rely on InheritedWidgets or singletons, Riverpod offers a compile-safe, testable, and composable way to manage state and dependencies. Whether you're building a small app or a large-scale enterprise application, understanding Riverpod can significantly improve your code quality and developer productivity.
In this comprehensive guide, we'll explore Riverpod in modern Flutter applications, covering everything from core concepts to advanced patterns, performance optimization, and real-world use cases. By the end, you'll have a solid understanding of how to leverage Riverpod effectively in your projects.
- Core concepts: Providers, WidgetRef, ConsumerWidget, and the ProviderScope.
- Provider types: When to use Provider, StateProvider, StateNotifierProvider, FutureProvider, and StreamProvider.
- Dependency injection: How Riverpod simplifies DI and makes testing easier.
- Code generation: Using riverpod_generator to reduce boilerplate.
- Best practices: Avoiding common pitfalls and optimizing performance.
What is Riverpod and Why It Matters in Flutter
Riverpod is a reactive caching and data-binding framework for Flutter and Dart. It was created by Remi Rousselet as a successor to the popular Provider package, addressing many of its limitations. Riverpod stands for "Provider" reversed, but it's not just a rename—it's a complete rethink of how state and dependencies should be managed.
At its core, Riverpod provides a way to declare global variables that hold state and expose them to widgets without relying on the widget tree. This means you can access providers from anywhere, including outside the widget tree, which is a game-changer for testing and architecture.
Key Advantages of Riverpod Over Other Solutions
- Compile-time safety: Riverpod catches missing or incorrectly scoped providers at compile time, eliminating runtime "ProviderNotFoundException" errors.
- No BuildContext required: Providers can be read without a BuildContext, making them easier to use in non-widget code like services or repositories.
- Testability: Providers are easily overridden in tests, allowing you to mock dependencies without complex setup.
- Composability: Providers can depend on other providers, enabling a clean, modular architecture.
- Auto-dispose and caching: Riverpod automatically disposes of providers when no longer needed, and caches results for performance.
These benefits make Riverpod particularly well-suited for modern Flutter applications that require scalability, maintainability, and robust testing.
Core Concepts of Riverpod
Before diving into code, let's clarify the fundamental concepts that form the foundation of Riverpod.
Providers
A Provider is the central building block in Riverpod. It encapsulates a piece of state or a dependency and exposes it to the rest of the app. Providers are declared globally and can be read, watched, or listened to by widgets.
Here's a simple Provider that returns a string:
final greetingProvider = Provider<String>((ref) {
return 'Hello, Riverpod!';
});
This provider can be accessed in a widget using ref.watch or ref.read.
WidgetRef and ConsumerWidget
To interact with providers, widgets need a WidgetRef. Riverpod provides the ConsumerWidget and ConsumerStatefulWidget classes, which give you access to a WidgetRef object. You can also use the Consumer widget to rebuild only part of the UI.
class GreetingWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final greeting = ref.watch(greetingProvider);
return Text(greeting);
}
}
When the provider's value changes, this widget automatically rebuilds.
ProviderScope
All Riverpod providers must be wrapped in a ProviderScope. This widget stores the state of all providers and is typically placed at the root of your Flutter app.
void main() {
runApp(ProviderScope(child: MyApp()));
}
Without a ProviderScope, Riverpod will throw an error. It's the equivalent of an InheritedWidget but for all providers.
Getting Started with Riverpod in a Flutter Project
To start using Riverpod, add the dependency to your pubspec.yaml. For the latest version, check pub.dev, but here's a typical setup:
dependencies:
flutter:
sdk: flutter
riverpod: ^2.4.0
flutter_riverpod: ^2.4.0
If you plan to use code generation, also add riverpod_generator and build_runner as dev dependencies.
After running flutter pub get, wrap your app in a ProviderScope as shown earlier. Now you can create and consume providers.
A Minimal Counter App with Riverpod
Let's build a classic counter app using Riverpod to demonstrate the basics.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final counterProvider = StateProvider<int>((ref) => 0);
void main() {
runApp(ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Riverpod Counter')),
body: Center(
child: Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('$count', style: TextStyle(fontSize: 48));
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Icon(Icons.add),
),
),
);
}
}
This example uses StateProvider, which is ideal for simple mutable state like a counter. The Consumer widget rebuilds only the Text when the count changes.
Types of Providers in Riverpod
Riverpod offers several provider types, each designed for specific use cases. Choosing the right one is crucial for clean and efficient state management.
Provider
The most basic provider. It returns a value that never changes unless its dependencies change. Use it for exposing services, repositories, or computed values.
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
StateProvider
For simple mutable state, like a counter or a toggle. It exposes a StateController that allows you to update the state directly.
final toggleProvider = StateProvider<bool>((ref) => false);
While convenient, StateProvider is not recommended for complex state logic. For that, use StateNotifierProvider.
StateNotifierProvider
This provider is designed for more complex state that involves business logic. It uses a StateNotifier class that holds immutable state and exposes methods to mutate it.
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
void decrement() => state--;
}
final counterNotifierProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
StateNotifierProvider is a solid choice for most state management needs, but it requires boilerplate. Riverpod 2.0 introduced NotifierProvider and AsyncNotifierProvider which are more modern alternatives.
FutureProvider
For asynchronous operations that return a single value, like fetching data from an API. It automatically handles loading and error states.
final userProvider = FutureProvider<User>((ref) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser();
});
StreamProvider
Similar to FutureProvider but for streams. It's perfect for real-time data like Firebase Firestore or WebSocket updates.
final messagesProvider = StreamProvider<List<Message>>((ref) {
final repository = ref.watch(messageRepositoryProvider);
return repository.messagesStream;
});
ChangeNotifierProvider (Legacy)
Riverpod also supports ChangeNotifierProvider for compatibility with existing ChangeNotifier classes. However, it's recommended to migrate to StateNotifier or Notifier for better performance and testability.
AutoDispose and Family Modifiers
Providers can be modified with .autoDispose to automatically dispose when no longer used, and .family to create multiple instances based on parameters.
final userProvider = FutureProvider.autoDispose.family<User, String>((ref, userId) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser(userId);
});
This provider fetches a user by ID and disposes the state when no widget is listening.
Dependency Injection and Composition with Riverpod
Riverpod excels at dependency injection (DI). Instead of using a service locator or passing dependencies manually, you can declare providers for your services and let Riverpod handle the wiring.
For example, a repository that depends on an API client:
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
final userRepositoryProvider = Provider<UserRepository>((ref) {
final apiClient = ref.watch(apiClientProvider);
return UserRepository(apiClient);
});
When you need to use the repository in a widget, you simply watch userRepositoryProvider. Riverpod ensures the API client is created once and reused.
Overriding Providers for Testing and Environments
One of Riverpod's killer features is the ability to override providers. This is invaluable for testing and for swapping implementations in different environments (e.g., development vs. production).
ProviderScope(
overrides: [
apiClientProvider.overrideWithValue(MockApiClient()),
],
child: MyApp(),
);
In tests, you can override providers with mocks or fakes without touching production code.
Managing State with StateNotifier and Riverpod
For complex state, StateNotifier combined with StateNotifierProvider is a robust pattern. It enforces immutable state and separates business logic from UI.
Let's build a simple todo list app.
class Todo {
final String id;
final String title;
final bool completed;
Todo({required this.id, required this.title, this.completed = false});
Todo copyWith({String? title, bool? completed}) {
return Todo(
id: id,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
}
class TodoState {
final List<Todo> todos;
final bool isLoading;
TodoState({this.todos = const [], this.isLoading = false});
TodoState copyWith({List<Todo>? todos, bool? isLoading}) {
return TodoState(
todos: todos ?? this.todos,
isLoading: isLoading ?? this.isLoading,
);
}
}
class TodoNotifier extends StateNotifier<TodoState> {
TodoNotifier() : super(TodoState());
void addTodo(String title) {
final newTodo = Todo(id: DateTime.now().toString(), title: title);
state = state.copyWith(todos: [...state.todos, newTodo]);
}
void toggleTodo(String id) {
state = state.copyWith(
todos: state.todos.map((todo) {
if (todo.id == id) {
return todo.copyWith(completed: !todo.completed);
}
return todo;
}).toList(),
);
}
}
final todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) {
return TodoNotifier();
});
In the UI, you can watch todoProvider and call methods on the notifier via ref.read(todoProvider.notifier).
This pattern scales well and keeps your code organized. For even less boilerplate, consider using NotifierProvider from Riverpod 2.0.
Asynchronous State Management with FutureProvider and StreamProvider
Handling asynchronous data is a common requirement. Riverpod's FutureProvider and StreamProvider simplify this by providing an AsyncValue that encapsulates loading, data, and error states.
final postProvider = FutureProvider.autoDispose.family<Post, int>((ref, postId) async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/$postId'));
if (response.statusCode == 200) {
return Post.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to load post');
}
});
In the UI, you can use when to handle each state:
class PostWidget extends ConsumerWidget {
final int postId;
PostWidget(this.postId);
@override
Widget build(BuildContext context, WidgetRef ref) {
final postAsync = ref.watch(postProvider(postId));
return postAsync.when(
data: (post) => Text(post.title),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);
}
}
This pattern eliminates boilerplate for loading and error handling, making your UI code cleaner.
Riverpod Code Generation
Riverpod 2.0 introduced code generation with the riverpod_generator package. It reduces boilerplate and provides better type safety. Instead of manually declaring providers, you annotate a function or class with @riverpod.
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter.g.dart';
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
After running dart run build_runner watch, the generated file provides a counterProvider that you can use in your widgets.
Code generation offers several benefits:
- Less boilerplate and more readable code.
- Automatic family and autoDispose support.
- Better refactoring support.
While optional, code generation is highly recommended for new projects.
Testing Riverpod Applications
Testing is a first-class citizen in Riverpod. Because providers are global and independent of the widget tree, you can test them in isolation.
For unit testing a provider, use ProviderContainer:
void main() {
test('counter increments', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
}
For widget tests, wrap your widget in a ProviderScope with overrides if needed.
testWidgets('counter shows incremented value', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('1'), findsOneWidget);
});
This makes it easy to write reliable tests for your state logic.
Performance Optimization with Riverpod
Riverpod is designed with performance in mind, but there are practices to ensure your app runs smoothly.
Use select to Limit Rebuilds
The select method allows you to watch only a specific part of a provider's state, reducing unnecessary rebuilds.
final userNameProvider = Provider<String>((ref) {
final user = ref.watch(userProvider);
return user.name;
});
// In widget:
final userName = ref.watch(userProvider.select((user) => user.name));
Leverage AutoDispose
For providers that are only needed temporarily, use .autoDispose to free resources when no longer in use.
Avoid Rebuilding Large Widget Trees
Use Consumer widgets around the smallest possible widget that needs the state.
Cache Expensive Computations
Providers can cache values. For example, a FutureProvider caches the result until invalidated.
By following these practices, you can build highly performant Flutter apps with Riverpod.
Common Mistakes and Best Practices
Even experienced developers can fall into traps when using Riverpod. Here are some common mistakes and how to avoid them.
Common Mistakes
- Using ref.read in build methods: This can cause missed updates. Use
ref.watchin build, andref.readin callbacks. - Overusing StateProvider: For complex state, use StateNotifier or Notifier instead.
- Forgetting ProviderScope: Without it, providers will throw errors.
- Not disposing providers: Use autoDispose for providers that should not persist.
- Creating providers inside widgets: Providers should be global to avoid re-creation and state loss.
Best Practices
- Organize providers by feature or layer (e.g.,
features/auth/providers.dart). - Use code generation for less boilerplate.
- Prefer immutable state and copyWith methods.
- Write tests for your notifiers and providers.
- Use
selectto optimize rebuilds. - Leverage
AsyncValuefor async operations.
Following these guidelines will lead to a clean, maintainable, and scalable codebase.
Security Considerations in Riverpod
While Riverpod itself is not a security tool, it can affect how you handle sensitive data. Here are some security considerations:
- Token storage: Never store sensitive tokens in plain providers. Use secure storage solutions like
flutter_secure_storageand expose them via providers that are properly scoped. - Provider overrides in production: Be cautious when overriding providers in production; ensure you don't accidentally expose debug or mock implementations.
- Data leakage: AutoDispose helps clear state, but ensure that sensitive data is not left in memory longer than needed.
- Dependency injection: When injecting API clients, ensure they are configured with proper authentication and HTTPS.
Always follow Flutter's security best practices and keep dependencies up to date.
Real-World Use Cases and Patterns
Riverpod shines in various scenarios. Let's explore some real-world use cases.
Authentication Flow
Manage auth state with a StateNotifier or AsyncNotifier. Expose the current user and provide methods for login/logout. Use ref.listen to navigate on auth changes.
Pagination and Infinite Scrolling
Use a StateNotifier to manage a list of items and loading state. Fetch more items when the user scrolls to the bottom.
Theme Switching
A simple StateProvider for the current theme mode. Watch it in MaterialApp to switch themes.
WebSocket and Real-Time Updates
Use StreamProvider to expose a stream of messages. The UI automatically updates as new data arrives.
These patterns demonstrate Riverpod's flexibility and power in real applications.
Riverpod vs. Other State Management Solutions
Choosing the right state management solution is crucial. Here's how Riverpod compares to other popular options.
Riverpod vs. Provider
Provider is simpler but lacks compile-time safety and requires BuildContext. Riverpod is more robust, testable, and doesn't depend on the widget tree.
Riverpod vs. BLoC
BLoC uses streams and events, which can be more verbose. Riverpod offers a simpler API and better dependency injection. BLoC is still a solid choice for complex event-driven architectures.
Riverpod vs. GetX
GetX is a microframework that combines state management, routing, and dependency injection. It's easy to use but can lead to tightly coupled code. Riverpod focuses solely on state and DI, promoting cleaner separation of concerns.
Ultimately, Riverpod is an excellent choice for most Flutter applications, especially those that prioritize testability and scalability.
Advanced Riverpod Patterns
Once you're comfortable with the basics, you can explore advanced patterns to further improve your architecture.
Using ref.listen for Side Effects
ref.listen allows you to react to state changes without rebuilding the widget. This is ideal for showing dialogs, snackbars, or navigating.
ref.listen<AuthState>(authProvider, (previous, next) {
if (next is Authenticated) {
Navigator.pushReplacementNamed(context, '/home');
}
});
Combining Providers
You can create providers that depend on multiple other providers, enabling complex derived state.
final filteredTodosProvider = Provider<List<Todo>>((ref) {
final todos = ref.watch(todoProvider).todos;
final filter = ref.watch(filterProvider);
return todos.where((todo) => filter == Filter.all || (filter == Filter.completed) == todo.completed).toList();
});
Scoped Providers
ProviderScope can be nested to create scoped overrides, useful for features like theming or multi-user sessions.
ProviderScope(
overrides: [
themeProvider.overrideWithValue(darkTheme),
],
child: FeatureWidget(),
);
These advanced patterns give you fine-grained control over your app's behavior.
Migrating from Provider to Riverpod
If you're currently using the Provider package, migrating to Riverpod can be done gradually. Here's a step-by-step approach.
- Add Riverpod dependencies alongside Provider.
- Wrap your app in a
ProviderScope. - Convert
ChangeNotifierProvidertoChangeNotifierProviderfrom Riverpod (or better, to StateNotifierProvider). - Replace
Provider.of<T>(context)withref.watchorref.read. - Test incrementally and refactor module by module.
This approach minimizes disruption and allows you to learn Riverpod while maintaining a working app.
Riverpod with Clean Architecture and Modularization
Riverpod integrates beautifully with Clean Architecture. You can define providers for each layer: data, domain, and presentation.
- Data layer: Providers for API clients, local databases, and repositories.
- Domain layer: Providers for use cases or interactors.
- Presentation layer: Providers for state notifiers and UI state.
This separation makes your codebase testable and maintainable. For example:
final apiClientProvider = Provider<ApiClient>((ref) => ApiClient());
final userRepositoryProvider = Provider<UserRepository>((ref) => UserRepository(ref.watch(apiClientProvider)));
final getUsersUseCaseProvider = Provider<GetUsersUseCase>((ref) => GetUsersUseCase(ref.watch(userRepositoryProvider)));
final userListNotifierProvider = StateNotifierProvider<UserListNotifier, UserListState>((ref) {
return UserListNotifier(ref.watch(getUsersUseCaseProvider));
});
This layered approach ensures that dependencies flow in one direction and are easily mockable.
Debugging Riverpod Applications
Riverpod provides excellent debugging tools. You can use the Riverpod DevTools extension to inspect provider states and dependencies in real-time.
- Provider Observer: Implement a custom
ProviderObserverto log provider changes. - DevTools: Visualize the provider graph and see which providers are being rebuilt.
- Logging: Add logging in your notifiers to trace state transitions.
For example, a simple ProviderObserver:
class MyObserver extends ProviderObserver {
@override
void didUpdateProvider(ProviderBase provider, Object? previousValue, Object? newValue, ProviderContainer container) {
print('Provider $provider updated: $previousValue -> $newValue');
}
}
void main() {
runApp(ProviderScope(observers: [MyObserver()], child: MyApp()));
}
This helps identify performance bottlenecks and unexpected state changes.
Riverpod with Freezed and JSON Serialization
Freezed is a popular package for creating immutable data classes. When combined with Riverpod, it provides a robust way to manage state. You can use Freezed to define state classes and then use them in your notifiers.
@freezed
class TodoState with _$TodoState {
factory TodoState({
@Default([]) List<Todo> todos,
@Default(false) bool isLoading,
}) = _TodoState;
}
Then your StateNotifier can emit these Freezed states. This reduces boilerplate and ensures immutability.
Riverpod with Flutter Hooks
Flutter Hooks is another library by Remi Rousselet that reduces boilerplate in StatefulWidgets. It works seamlessly with Riverpod. You can use HookConsumerWidget to combine both.
class MyWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
final controller = useTextEditingController();
return TextField(controller: controller);
}
}
This combination is powerful for managing local widget state alongside global app state.
Frequently Asked Questions About Riverpod
What is the difference between Riverpod and Provider?
Riverpod is the successor to Provider. It offers compile-time safety, no dependency on BuildContext, easier testing, and better performance. Provider relies on the widget tree, while Riverpod uses a global ProviderScope.
Do I need to use code generation with Riverpod?
No, code generation is optional. You can write providers manually. However, code generation reduces boilerplate and provides better type safety, so it's recommended for new projects.
How does Riverpod handle async operations?
Riverpod provides FutureProvider and StreamProvider for async operations. They return an AsyncValue that encapsulates loading, data, and error states, simplifying UI code.
Can I use Riverpod with other state management solutions?
Yes, Riverpod can be used alongside other solutions, but it's typically a complete replacement. You can gradually migrate parts of your app.
Is Riverpod suitable for large-scale applications?
Absolutely. Riverpod's modularity, testability, and performance features make it ideal for large-scale, enterprise-grade Flutter applications.
How do I test a Riverpod provider?
Use ProviderContainer for unit tests. For widget tests, wrap your widget in ProviderScope and use overrides to inject mocks.
What is the difference between StateNotifier and Notifier?
Notifier is a newer, simpler API introduced in Riverpod 2.0. It doesn't require a separate class for state and reduces boilerplate. StateNotifier is still supported but Notifier is recommended for new code.
Can Riverpod be used without Flutter?
Yes, Riverpod is a pure Dart package and can be used in any Dart project, including server-side or CLI applications.
Can I use Riverpod with Flutter Web and Desktop?
Yes, Riverpod is platform-agnostic and works on iOS, Android, Web, Desktop, and even server-side Dart.
Does Riverpod support hot reload?
Yes, Riverpod is designed to work with hot reload. State is preserved across reloads when using ProviderScope.
What is the learning curve for Riverpod?
Riverpod has a moderate learning curve. If you're familiar with Provider, you'll pick it up quickly. The concepts are well-documented and the community is active.
Adopting Riverpod in Your Flutter Projects
Riverpod is more than just a state management library—it's a complete dependency injection and reactive programming framework that can transform how you build Flutter apps. By providing compile-time safety, excellent testability, and powerful composition, it addresses many pain points of traditional approaches.
To get started, begin with small features and gradually expand. Leverage code generation to reduce boilerplate, and follow best practices to keep your codebase clean. With Riverpod in your toolkit, you'll be well-equipped to build modern, scalable Flutter applications.
Remember: the key to mastering Riverpod is practice. Start with simple providers, then move to StateNotifier and async providers. Soon, you'll wonder how you ever managed state without it.