Managing state is one of the most important aspects of building modern Flutter applications. As apps grow in complexity, handling state transitions, dependencies, and data flow can become a real challenge. That’s where Riverpod, a modern and powerful state management library, comes in.
Riverpod is designed to overcome the limitations of the Provider package while maintaining simplicity, type safety, and testability. Whether you’re building a small app or a large production-ready system, Riverpod offers the flexibility and reliability you need.
In this tutorial, we’ll take a deep dive into Flutter state management with Riverpod. You’ll learn the fundamentals, explore practical examples, and discover advanced patterns to take full advantage of Riverpod in your projects.
By the end, you’ll have a clear understanding of:
-
Why Riverpod is a better choice over traditional Provider.
-
How to set up Riverpod in a Flutter project.
-
Managing different types of state (simple, asynchronous, and complex).
-
Using
StateProvider
,FutureProvider
,StreamProvider
, andStateNotifierProvider
. -
Best practices and patterns for real-world applications.
Section 1: Why Riverpod?
Before jumping into code, let’s understand why Riverpod exists and how it improves upon other state management solutions.
The Problem with Provider
Provider has been one of the most popular state management solutions in Flutter, but it comes with some limitations:
-
Context-dependent: Accessing providers often requires
BuildContext
, making some operations less flexible. -
Complex refactoring: Changing provider types (e.g.,
ChangeNotifierProvider
toStateNotifierProvider
) requires significant code changes. -
Global access issues: Sometimes managing providers across multiple parts of an app becomes tricky.
How Riverpod Solves These Issues
Riverpod was built from the ground up to address these limitations:
-
Compile-time safety → Riverpod provides better type-safety, catching errors early.
-
No BuildContext needed → You can read providers anywhere, not just inside widgets.
-
Refactor-friendly → Switching provider types is straightforward.
-
Scalability → Ideal for both small and enterprise-level Flutter apps.
-
Testability → Makes mocking and testing state management much easier.
When to Use Riverpod
-
When building medium to large apps with complex state logic.
-
When you need predictable, testable, and maintainable state management.
-
When you want more control and flexibility compared to the Provider.
Section 2: Project Setup (Flutter + Riverpod Installation)
Before we start coding, let’s set up a new Flutter project and add Riverpod as a dependency.
Step 1: Create a New Flutter Project
Open your terminal and run the following command to create a new Flutter project:
flutter create flutter_riverpod_example
Navigate into the project directory:
cd flutter_riverpod_example
You can open the project in your preferred IDE (VS Code, Android Studio, or IntelliJ).
Step 2: Add Riverpod Dependency
In Flutter, Riverpod comes in two main variants:
-
flutter_riverpod → For Flutter apps (widgets + UI integration).
-
riverpod → For pure Dart projects (backend, CLI tools, etc.).
Since we are building a Flutter app, install flutter_riverpod
:
flutter pub add flutter_riverpod
This will add the latest stable version to your pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.0.1 # (example version, yours may vary)
Step 3: Wrap Your App with ProviderScope
Riverpod requires a ProviderScope
at the root of your widget tree. This ensures that all providers can be accessed throughout the app.
Open lib/main.dart
and update it like this:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Riverpod Example',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('Hello Riverpod!'),
),
);
}
}
At this point, you’ve successfully set up Riverpod in your Flutter project. 🎉
Section 3: Basic Providers (Provider & StateProvider)
Riverpod offers different types of providers depending on your use case. Let’s start with the simplest ones:
-
Provider
→ Provides read-only values. -
StateProvider
→ Provides a mutable state that can be updated.
3.1 Using Provider
(Read-Only Values)
The Provider
is useful when you just want to expose a value that doesn’t change.
Example: Providing a String
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define a Provider
final helloProvider = Provider<String>((ref) {
return 'Hello from Riverpod!';
});
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Step 2: Read the provider
final message = ref.watch(helloProvider);
return Scaffold(
appBar: AppBar(title: const Text('Provider Example')),
body: Center(
child: Text(message, style: const TextStyle(fontSize: 20)),
),
);
}
}
✅ What happens here?
-
We defined a
Provider
that returns a string. -
Inside
HomePage
, we usedref.watch(helloProvider)
to access its value. -
The UI displays the value directly.
3.2 Using StateProvider
(Mutable State)
StateProvider
is the simplest way to manage a piece of mutable state, like a counter.
Example: Counter App with StateProvider
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define a StateProvider
final counterProvider = StateProvider<int>((ref) => 0);
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Step 2: Read the state
final count = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('StateProvider Example')),
body: Center(
child: Text('Count: $count', style: const TextStyle(fontSize: 24)),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Step 3: Update the state
ref.read(counterProvider.notifier).state++;
},
child: const Icon(Icons.add),
),
);
}
}
✅ What happens here?
-
We created a
StateProvider<int>
that starts at0
. -
ref.watch(counterProvider)
listens to state changes and rebuilds the UI automatically. -
ref.read(counterProvider.notifier).state++
updates the counter.
When to Use
-
Provider
→ Use when your value is constant or derived from other providers (read-only). -
StateProvider
→ Use for simple, mutable state (like toggles, counters, or text inputs).
🚀 You’ve just learned the basics of Riverpod with Provider
and StateProvider
.
Section 4: Async Providers (FutureProvider & StreamProvider)
Modern apps often rely on asynchronous data, such as fetching from an API, reading from a database, or listening to a stream of updates. Riverpod provides two providers for these use cases:
-
FutureProvider
→ For asynchronous data that resolves once (like an HTTP request). -
StreamProvider
→ For continuous streams of data (like WebSockets or Firebase).
4.1 Using FutureProvider
FutureProvider
is designed for operations that return a Future
.
Example: Fetching Data from an API
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
// Step 1: Define a FutureProvider
final userProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/1'));
return json.decode(response.body);
});
class UserPage extends ConsumerWidget {
const UserPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Step 2: Watch the FutureProvider
final userAsync = ref.watch(userProvider);
return Scaffold(
appBar: AppBar(title: const Text('FutureProvider Example')),
body: userAsync.when(
data: (user) => Center(
child: Text('Name: ${user['name']}', style: const TextStyle(fontSize: 20)),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}
✅ Key Points:
-
FutureProvider
handles loading, success, and error states automatically. -
The
when()
method lets you purely handle each state.
4.2 Using StreamProvider
StreamProvider
is perfect for real-time data sources such as WebSockets, Firebase, or even a simple Dart Stream
.
Example: A Simple Timer Stream
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define a StreamProvider
final timerProvider = StreamProvider<int>((ref) {
return Stream.periodic(const Duration(seconds: 1), (count) => count);
});
class TimerPage extends ConsumerWidget {
const TimerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Step 2: Watch the StreamProvider
final timerAsync = ref.watch(timerProvider);
return Scaffold(
appBar: AppBar(title: const Text('StreamProvider Example')),
body: timerAsync.when(
data: (value) => Center(
child: Text('Seconds elapsed: $value', style: const TextStyle(fontSize: 24)),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}
✅ Key Points:
-
StreamProvider
listens to the stream and rebuilds the UI on every new value. -
Like
FutureProvider
, it has built-in loading, data, and error handling.
When to Use
-
FutureProvider
→ For one-time async operations (e.g., API requests, local storage fetch). -
StreamProvider
→ For continuous updates (e.g., timers, sockets, Firebase Firestore).
🎯 With FutureProvider
and StreamProvider
, you can now handle asynchronous state elegantly in your Flutter apps.
Section 5: StateNotifierProvider & ChangeNotifierProvider
For more complex state management scenarios, Riverpod provides providers that let you manage structured and reactive state in a separate class, making your code more maintainable and testable.
Two commonly used options are:
-
StateNotifierProvider
→ Manages state using an immutable state object. -
ChangeNotifierProvider
→ Similar to Provider’sChangeNotifier
, but with Riverpod’s improvements.
5.1 Using StateNotifierProvider
StateNotifierProvider
is ideal when you need:
-
More control over state updates.
-
Complex logic (like authentication, form validation, or managing lists).
-
Immutable state (better for predictability).
Example: Todo List with StateNotifier
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define a state model
class Todo {
final String title;
final bool completed;
Todo({
required this.title,
this.completed = false,
});
Todo copyWith({String? title, bool? completed}) {
return Todo(
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
}
// Step 2: Create a StateNotifier
class TodoNotifier extends StateNotifier<List<Todo>> {
TodoNotifier() : super([]);
void add(String title) {
state = [...state, Todo(title: title)];
}
void toggle(int index) {
final todo = state[index];
state = [
for (int i = 0; i < state.length; i++)
if (i == index) todo.copyWith(completed: !todo.completed) else state[i],
];
}
}
// Step 3: Define a StateNotifierProvider
final todoProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
return TodoNotifier();
});
class TodoPage extends ConsumerWidget {
const TodoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todoProvider);
final controller = TextEditingController();
return Scaffold(
appBar: AppBar(title: const Text('StateNotifierProvider Example')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Add a todo',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if (value.isNotEmpty) {
ref.read(todoProvider.notifier).add(value);
controller.clear();
}
},
),
),
Expanded(
child: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
),
),
trailing: Checkbox(
value: todo.completed,
onChanged: (_) {
ref.read(todoProvider.notifier).toggle(index);
},
),
);
},
),
),
],
),
);
}
}
✅ Key Points:
-
StateNotifier
keeps logic separate from UI. -
State updates are immutable (
state = newValue
). -
Easy to test and maintain.
5.2 Using ChangeNotifierProvider
If you already use ChangeNotifier
(from Provider package), Riverpod provides a bridge with ChangeNotifierProvider
.
Example: Counter with ChangeNotifier
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define a ChangeNotifier
class CounterNotifier extends ChangeNotifier {
int count = 0;
void increment() {
count++;
notifyListeners();
}
}
// Step 2: Define a ChangeNotifierProvider
final counterProvider = ChangeNotifierProvider<CounterNotifier>((ref) {
return CounterNotifier();
});
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('ChangeNotifierProvider Example')),
body: Center(
child: Text('Count: ${counter.count}', style: const TextStyle(fontSize: 24)),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider).increment(),
child: const Icon(Icons.add),
),
);
}
}
✅ Key Points:
-
Useful when migrating from Provider.
-
Still works with Riverpod’s
ref.watch
andref.read
. -
But
StateNotifierProvider
is preferred for new projects (cleaner & immutable state).
When to Use
-
StateNotifierProvider
→ Preferred for new projects, complex logic, and immutable state. -
ChangeNotifierProvider
→ Good for migrating existing Provider-based code to Riverpod.
🎯 With these providers, you can now manage structured and scalable state in your apps.
Section 6: Scoped & Family Providers
Sometimes, you’ll want to create parameterized providers (providers that depend on an argument) or limit a provider’s scope to a specific part of the widget tree. Riverpod gives us two powerful tools for this:
-
Family Providers → Allows passing parameters into a provider.
-
Scoped Providers → Lets you override providers locally in a widget subtree.
6.1 Family Providers
The family
modifier lets you pass arguments to a provider at runtime.
Example: Fetching a User by ID
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
// Step 1: Create a FutureProvider.family
final userProvider = FutureProvider.family<Map<String, dynamic>, int>((ref, userId) async {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'));
return json.decode(response.body);
});
class UserDetailPage extends ConsumerWidget {
final int userId;
const UserDetailPage({super.key, required this.userId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Step 2: Pass parameter when watching
final userAsync = ref.watch(userProvider(userId));
return Scaffold(
appBar: AppBar(title: Text('User $userId')),
body: userAsync.when(
data: (user) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Name: ${user['name']}\nEmail: ${user['email']}',
style: const TextStyle(fontSize: 18)),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}
✅ Key Points:
-
FutureProvider.family
allows different user IDs to fetch different data. -
Useful for API requests, filtering, or item-specific state.
6.2 Scoped Providers (Overrides)
Scoped providers let you override the value of a provider in a specific widget subtree.
Example: Local Counter Override
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define a provider
final greetingProvider = Provider<String>((ref) => 'Hello from Global!');
class ScopedExamplePage extends StatelessWidget {
const ScopedExamplePage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
// Step 2: Override the provider for this subtree
greetingProvider.overrideWithValue('Hello from Scoped Provider!'),
],
child: const ScopedChild(),
);
}
}
class ScopedChild extends ConsumerWidget {
const ScopedChild({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final message = ref.watch(greetingProvider);
return Scaffold(
appBar: AppBar(title: const Text('Scoped Provider Example')),
body: Center(
child: Text(message, style: const TextStyle(fontSize: 20)),
),
);
}
}
✅ Key Points:
-
Scoped providers are local overrides.
-
Useful for testing, theming, or customizing behavior in specific screens.
-
The override affects only the subtree wrapped with
ProviderScope
.
When to Use
-
Family Providers → When your state/data depends on parameters (IDs, filters, etc.).
-
Scoped Providers → When you need different behaviors/values in specific parts of your app.
🎯 Now you know how to pass parameters into providers and override providers locally for more flexibility.
Section 7: Combining Providers & Computed State
In real-world applications, you’ll often need to derive new values based on multiple providers. Riverpod makes this straightforward with its reactive provider system.
You can:
-
Combine multiple providers to compute new values.
-
Automatically rebuild UI when dependencies change.
-
Keep your business logic separate from widgets.
7.1 Combining Providers
Let’s say we have two providers and we want to compute a result based on both.
Example: Full Name Provider
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Step 1: Define base providers
final firstNameProvider = Provider<String>((ref) => 'John');
final lastNameProvider = Provider<String>((ref) => 'Doe');
// Step 2: Define a computed provider
final fullNameProvider = Provider<String>((ref) {
final firstName = ref.watch(firstNameProvider);
final lastName = ref.watch(lastNameProvider);
return '$firstName $lastName';
});
class FullNamePage extends ConsumerWidget {
const FullNamePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fullName = ref.watch(fullNameProvider);
return Scaffold(
appBar: AppBar(title: const Text('Computed Provider Example')),
body: Center(
child: Text(fullName, style: const TextStyle(fontSize: 24)),
),
);
}
}
✅ Key Points:
-
fullNameProvider
automatically recomputes if eitherfirstNameProvider
orlastNameProvider
changes. -
Great for derived state (like total price, full name, formatted values).
7.2 Combining Async Providers
You can also combine async providers (Future/Stream).
Example: Combine Two FutureProviders
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
// Step 1: Define FutureProviders
final userProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final res = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/1'));
return json.decode(res.body);
});
final postProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final res = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/posts/1'));
return json.decode(res.body);
});
// Step 2: Combine providers
final combinedProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final user = await ref.watch(userProvider.future);
final post = await ref.watch(postProvider.future);
return {
'userName': user['name'],
'postTitle': post['title'],
};
});
class CombinedPage extends ConsumerWidget {
const CombinedPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final combinedAsync = ref.watch(combinedProvider);
return Scaffold(
appBar: AppBar(title: const Text('Combining Providers Example')),
body: combinedAsync.when(
data: (data) => Padding(
padding: const EdgeInsets.all(16),
child: Text(
'${data['userName']} wrote:\n\n"${data['postTitle']}"',
style: const TextStyle(fontSize: 18),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}
✅ Key Points:
-
ref.watch(...future)
lets you wait for async values. -
Computed provider updates automatically when dependencies change.
-
Useful for joined data (e.g., user + posts, cart items + prices).
7.3 Best Use Cases for Computed State
-
Combining user info + preferences.
-
Calculating totals (cart, scores, analytics).
-
Formatting or transforming data for display.
-
Aggregating results from multiple APIs.
🎯 With computed and combined providers, you can create reactive, declarative logic that keeps your UI in sync with state changes.
Section 8: Persisting State with Riverpod
In many apps, you’ll want state to persist across sessions—for example, saving user settings, authentication tokens, or preferences. Riverpod doesn’t provide persistence by itself, but it integrates easily with storage solutions such as:
-
SharedPreferences → Key-value storage for small data.
-
Hive → Lightweight NoSQL database for Flutter.
-
SQLite / Drift → Relational database for structured data.
In this section, we’ll use SharedPreferences to persist a simple state with Riverpod.
8.1 Example: Persisting a Theme Setting
Let’s say we want to toggle between light mode and dark mode, and keep that preference saved.
Step 1: Add Dependency
Run:
flutter pub add shared_preferences
Step 2: Create a Notifier with Persistence
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Step 1: Create a StateNotifier for theme mode
class ThemeNotifier extends StateNotifier<ThemeMode> {
ThemeNotifier() : super(ThemeMode.light) {
_loadTheme();
}
// Load theme from SharedPreferences
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
final isDark = prefs.getBool('isDark') ?? false;
state = isDark ? ThemeMode.dark : ThemeMode.light;
}
// Toggle theme and save it
Future<void> toggleTheme() async {
final prefs = await SharedPreferences.getInstance();
final isDark = state == ThemeMode.dark;
state = isDark ? ThemeMode.light : ThemeMode.dark;
await prefs.setBool('isDark', !isDark);
}
}
// Step 2: Create a provider
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>((ref) {
return ThemeNotifier();
});
Step 3: Use the Provider in the App
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeProvider);
return MaterialApp(
title: 'Flutter Riverpod Persistence',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: themeMode, // 👈 Apply persisted theme
home: const ThemePage(),
);
}
}
class ThemePage extends ConsumerWidget {
const ThemePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeNotifier = ref.read(themeProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Persisted Theme Example')),
body: Center(
child: ElevatedButton(
onPressed: () => themeNotifier.toggleTheme(),
child: const Text('Toggle Theme'),
),
),
);
}
}
✅ How it works:
-
On startup,
_loadTheme()
retrieves the saved preference. -
When the button is pressed, it
toggleTheme()
updates both the Riverpod state and the SharedPreferences. -
The app theme persists across restarts.
8.2 Other Persistence Options
-
Hive → Store structured objects (great for offline apps).
-
Drift/SQLite → Store relational data (complex schemas).
-
Secure Storage → Store sensitive data like tokens.
🎯 With persistence integrated, your Riverpod state can now survive app restarts, making your apps user-friendly and production-ready.
Section 9: Best Practices for Riverpod
Riverpod makes state management powerful and maintainable, but to keep your code clean, scalable, and production-ready, it’s important to follow some best practices.
9.1 Structure Your Providers Clearly
-
Keep providers in separate files (e.g.,
auth_provider.dart
,theme_provider.dart
). -
Group related providers into feature modules to avoid a messy
main.dart
.
✅ Example project structure:
lib/
├─ main.dart
├─ providers/
│ ├─ auth_provider.dart
│ ├─ theme_provider.dart
│ └─ counter_provider.dart
└─ screens/
├─ home_page.dart
└─ login_page.dart
9.2 Avoid Over-Watching Providers
-
Use
ref.watch()
only where UI updates are necessary. -
Use
ref.read()
for one-time actions (e.g., button clicks, async triggers).
✅ Example:
// Good practice
final counter = ref.watch(counterProvider); // UI updates
ref.read(counterProvider.notifier).increment(); // Action only
9.3 Keep Business Logic in Notifiers
-
Don’t mix business logic directly into widgets.
-
Encapsulate state changes inside StateNotifier or Notifier classes.
✅ Example:
// BAD ❌
onPressed: () => ref.read(counterProvider.notifier).state++;
// GOOD ✅
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
}
9.4 Handle Errors Gracefully
-
Use
AsyncValue
(AsyncValue.data
,.loading
,.error
) to manage network state. -
Show friendly error messages instead of crashing.
✅ Example:
ref.watch(userProvider).when(
data: (user) => Text('Hello ${user.name}'),
loading: () => const CircularProgressIndicator(),
error: (err, _) => Text('Error: $err'),
);
9.5 Use ref.listen
for Side Effects
-
For navigation, dialogs, or snackbars, prefer
ref.listen()
instead of building UI directly from provider changes.
✅ Example:
ref.listen<AuthState>(authProvider, (prev, next) {
if (next.isLoggedIn) {
Navigator.pushReplacementNamed(context, '/home');
}
});
9.6 Test Your Providers
-
Riverpod makes providers test-friendly.
-
Use
ProviderContainer
in unit tests.
✅ Example test:
void main() {
test('counter increments', () {
final container = ProviderContainer();
final notifier = container.read(counterProvider.notifier);
notifier.increment();
expect(container.read(counterProvider), 1);
});
}
9.7 Optimize Performance
-
Prefer
StateNotifierProvider
for a complex state instead ofStateProvider
. -
Use select to watch only parts of the state:
final userName = ref.watch(userProvider.select((u) => u.name));
This reduces unnecessary rebuilds.
9.8 Persistence & Security
-
Use
SharedPreferences
orHive
for general persistence. -
Use flutter_secure_storage for sensitive data like tokens.
📌 Following these best practices ensures your Riverpod-powered app stays scalable, performant, and maintainable as it grows.
Section 10: Conclusion + Final Thoughts
In this tutorial, we explored Flutter state management with Riverpod, starting from the basics all the way to advanced use cases. Here’s a quick recap of what we covered:
-
✅ Setup: How to add Riverpod and configure your project with
ProviderScope
. -
✅ Basic Providers: Using
Provider
andStateProvider
for a simple state. -
✅ Async Providers: Managing network requests with
FutureProvider
andStreamProvider
. -
✅ StateNotifier & Notifier: Encapsulating business logic and creating reusable state management patterns.
-
✅ Scoped Overrides: Providing different values for providers in different parts of the app.
-
✅ Persistence: Storing state across app restarts with
SharedPreferences
orHive
. -
✅ Best Practices: Organizing providers, avoiding unnecessary rebuilds, handling errors gracefully, and writing tests.
Why Riverpod?
Riverpod is not just a replacement for Provider — it’s a more robust, testable, and flexible approach to managing state in Flutter. Its declarative API, compile-time safety, and ability to handle both synchronous and asynchronous state make it a top choice for production apps.
When to Use Riverpod
-
Small apps need cleaner state management.
-
Medium-to-large apps require complex state handling, dependency injection, and testability.
-
Teams are looking for a future-proof, well-maintained state management solution.
Final Thoughts
Riverpod strikes an excellent balance between simplicity and power. By following the patterns and best practices from this guide, you can confidently build apps that are scalable, maintainable, and efficient.
If you want to go further, here are some ideas:
-
Combine Riverpod with Flutter Hooks for even cleaner widget logic.
-
Explore code generation with
riverpod_generator
for less boilerplate. -
Apply Riverpod to real-world features like authentication, caching, or theming.
🎉 Congratulations! You now have a solid understanding of Riverpod in Flutter. Go ahead and start applying it to your next Flutter project.
You can find the example source code on our GitHub.
That's just the basics. If you need more deep learning about Flutter and Dart, you can take the following cheap course:
- Flutter & Dart - The Complete Guide [2025 Edition]
- The Complete Flutter Development Bootcamp with Dart
- Complete Flutter Guide 2025: Build Android, iOS and Web apps
- Advanced Flutter: Build Enterprise-Ready Apps.
- Flutter , Nodejs, Express , MongoDB: Build Multi-Store App
- Flutter & Dart Essentials-Build Mobile Apps like a Pro
- Master Flutter with ML: Object Detection, OCR & Beyond
- Full-Stack Mobile Development: Flutter, Figma, and Firebase
- Dart & Flutter - Zero to Mastery [2025] + Clean Architecture
- Master Flame Game Engine with Flutter: Build 2D Mobile Games
Thanks!