Flutter State Management with Riverpod 3
Learn how to manage application state in Flutter with Riverpod 3, including Providers, Notifiers, AsyncNotifier, Code Generation, dependency injection, testing, and application architecture.
Published on • September 21, 2026
AI Assistant

State management is one of the most important challenges in Flutter application development. As an application grows, state is no longer limited to individual Widgets. It becomes connected to APIs, databases, authentication, caching, business logic, and data shared across multiple screens.
Riverpod 3 is a reactive caching and data-binding framework for Dart and Flutter. It helps separate state and business logic from the UI while providing support for asynchronous operations, dependency injection, and testing.
This article uses Riverpod 3.4.x and focuses on APIs recommended for new code.
1. Installing Riverpod 3
For a Flutter application, install flutter_riverpod:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.4.3
dev_dependencies:
riverpod_generator: ^3.0.0
build_runner: ^2.7.0
The source article uses Riverpod 3.4.3 as the version available on pub.dev at the time it was written.
If you use Code Generation, the additional packages are:
riverpod_generator
build_runner
Code Generation reduces boilerplate and provides a consistent way to define parameterized Providers and Notifiers.
2. ProviderScope
Start Riverpod at the root of the application:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
ProviderScope is the container for the application’s Providers.
A simple mental model looks like this:
ProviderScope
|
+-- User Provider
|
+-- Todo Provider
|
+-- API Provider
|
+-- Settings Provider
Widgets inside ProviderScope can access Riverpod.
3. ConsumerWidget
A Widget that needs to read a Provider can change from:
StatelessWidget
to:
ConsumerWidget
For example:
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(
BuildContext context,
WidgetRef ref,
) {
final message = ref.watch(messageProvider);
return Scaffold(
body: Center(
child: Text(message),
),
);
}
}
WidgetRef provides the connection between the Widget and Riverpod.
The basic relationship is:
flowchart TD ConsumerWidget --> WidgetRef WidgetRef --> watch WidgetRef --> read WidgetRef --> listen
4. A Simple Provider
A simple Provider is useful for values calculated from other dependencies or values that do not require mutable state.
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'app_provider.g.dart';
@riverpod
String appName(Ref ref) {
return 'Todo App';
}
After running the code generator, you can use:
final name = ref.watch(appNameProvider);
For example, in a Widget:
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(
BuildContext context,
WidgetRef ref,
) {
final name = ref.watch(appNameProvider);
return Scaffold(
appBar: AppBar(
title: Text(name),
),
);
}
}
Code Generation reduces boilerplate and provides a consistent approach for defining different kinds of Providers.
5. What Is ref.watch()?
The following:
ref.watch(provider)
means:
Read the value from a Provider and subscribe to its changes.
For example:
final count = ref.watch(counterProvider);
When the Provider changes, a Widget watching that Provider can rebuild according to the Provider’s lifecycle.
The basic flow is:
flowchart TD A[Provider] -->|state changes| B["ref.watch()"] B --> C[Widget rebuild]
6. Notifier: Managing Mutable State in Riverpod 3
For state that changes in response to user interaction, use a Notifier.
Here is a simple Counter:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter_provider.g.dart';
@riverpod
class Counter extends _$Counter {
@override
int build() {
return 0;
}
void increment() {
state++;
}
void decrement() {
state--;
}
}
Code Generation creates:
counterProvider
automatically.
The UI can then read the state:
final count = ref.watch(counterProvider);
and invoke a method:
ref.read(counterProvider.notifier).increment();
7. watch vs. read
This is one of the most important concepts in Riverpod.
Read and Observe State
ref.watch(counterProvider);
Use watch when the UI needs to rebuild when the state changes.
Perform an Action
ref.read(counterProvider.notifier).increment();
Use read when you want to invoke a method on a Notifier.
A useful mental model is:
flowchart TD
A[watch] --> B[Observe State]
C["read(...notifier)"] --> D[Perform Action]
For example:
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(
BuildContext context,
WidgetRef ref,
) {
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(
child: Text(
'$count',
style: const TextStyle(fontSize: 48),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref
.read(counterProvider.notifier)
.increment();
},
child: const Icon(Icons.add),
),
);
}
}
8. AsyncNotifier
In real-world applications, state often comes from an API.
A typical architecture looks like this:
flowchart TD UI[Flutter UI] --> N[AsyncNotifier] N --> R[Repository] R --> API[REST API]
Riverpod provides AsyncNotifier for state that requires asynchronous initialization and can expose methods for changing that state.
For example:
@riverpod
class Todos extends _$Todos {
@override
Future<List<Todo>> build() async {
return fetchTodos();
}
Future<List<Todo>> fetchTodos() async {
await Future.delayed(
const Duration(seconds: 1),
);
return const [
Todo(
id: '1',
title: 'Learn Riverpod',
),
Todo(
id: '2',
title: 'Build Flutter App',
),
];
}
}
Code Generation creates:
todosProvider
The state is represented as:
AsyncValue<List<Todo>>
9. AsyncValue
AsyncValue represents the main states of an asynchronous operation:
flowchart TD A[AsyncValue] --> B[Loading] A --> C[Data] A --> D[Error]
Riverpod 3 makes AsyncValue a sealed type, allowing Dart pattern matching to express these states clearly.
For example:
class TodoPage extends ConsumerWidget {
const TodoPage({super.key});
@override
Widget build(
BuildContext context,
WidgetRef ref,
) {
final todos = ref.watch(todosProvider);
return switch (todos) {
AsyncData(:final value) =>
TodoList(todos: value),
AsyncError(:final error) =>
Center(
child: Text('Error: $error'),
),
AsyncLoading() =>
const Center(
child: CircularProgressIndicator(),
),
};
}
}
This approach is easier to read than deeply nested if statements and makes each state explicit.
10. Todo Model
Create a model for the Todo:
class Todo {
const Todo({
required this.id,
required this.title,
this.completed = false,
});
final String id;
final String title;
final bool completed;
Todo copyWith({
String? title,
bool? completed,
}) {
return Todo(
id: id,
title: title ?? this.title,
completed: completed ?? this.completed,
);
}
}
11. Todo Notifier
The Notifier can combine data loading with business logic:
@riverpod
class Todos extends _$Todos {
@override
Future<List<Todo>> build() async {
return repository.getTodos();
}
Future<void> addTodo(String title) async {
final todo = await repository.createTodo(title);
final current = state.value ?? [];
state = AsyncData([
...current,
todo,
]);
}
Future<void> toggleTodo(String id) async {
final current = state.value ?? [];
final updated = [
for (final todo in current)
if (todo.id == id)
todo.copyWith(
completed: !todo.completed,
)
else
todo,
];
state = AsyncData(updated);
}
}
The UI does not need to know how the Todo data is loaded from the API.
It only needs to invoke:
ref
.read(todosProvider.notifier)
.addTodo('Learn Riverpod');
This keeps the UI separated from the implementation details of the data layer.
12. Dependency Injection
Riverpod can also act as a dependency injection container.
Consider a Repository:
class TodoRepository {
const TodoRepository(this.api);
final ApiClient api;
Future<List<Todo>> getTodos() async {
// API request
return [];
}
}
Create an API Provider:
@riverpod
ApiClient apiClient(Ref ref) {
return ApiClient();
}
Then create a Repository Provider:
@riverpod
TodoRepository todoRepository(Ref ref) {
final api = ref.watch(apiClientProvider);
return TodoRepository(api);
}
The Notifier can then use the Repository:
@riverpod
class Todos extends _$Todos {
@override
Future<List<Todo>> build() {
final repository =
ref.watch(todoRepositoryProvider);
return repository.getTodos();
}
}
The resulting dependency graph is:
flowchart TD ApiClient --> TodoRepository TodoRepository --> TodosNotifier[Todos Notifier] TodosNotifier --> FlutterUI[Flutter UI]
Each component has a clear responsibility, and dependencies can be overridden in tests.
13. Family
When a Provider needs a parameter, such as a User ID, define the parameter directly:
@riverpod
Future<User> user(
Ref ref,
String id,
) async {
final repository =
ref.watch(userRepositoryProvider);
return repository.getUser(id);
}
From the UI:
final user = ref.watch(
userProvider('user-123'),
);
This allows Riverpod to maintain separate state for each parameter:
flowchart TD
Provider["userProvider"] --> Alice["userProvider("alice")<br/>Alice"]
Provider --> Bob["userProvider("bob")<br/>Bob"]
Riverpod 3 supports parameters in generated Providers directly, and parameterized Notifiers can receive their values through build().
14. ref.listen()
Sometimes you do not want to rebuild the UI. Instead, you want to perform a side effect when state changes.
For example:
ref.listen(todosProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to load todos'),
),
);
}
});
The three APIs have different purposes:
flowchart LR A[Riverpod API] --> B[ref.watch\nRebuild UI] A --> C[ref.read\nRead or perform action] A --> D[ref.listen\nReact with side effect]
Riverpod 3 also provides lifecycle capabilities for listeners, including pause/resume behavior, and listeners can return a function to cancel the subscription.
15. Automatic Retry
One of the capabilities introduced in Riverpod 3 is Automatic Retry.
When a Provider fails during initialization, Riverpod can retry using exponential backoff. The default behavior starts at approximately 200 ms and increases up to 6.4 seconds.
Conceptually:
flowchart TD
A[Request] --> B{Success?}
B -->|Yes| C[Success]
B -->|No| D[Retry after exponential backoff]
D --> E{Success?}
E -->|Yes| C
E -->|No| F[Retry up to the configured limit]
F --> C
Retry behavior can be configured at the ProviderScope or ProviderContainer level when an application requires a different policy.
16. Ref.mounted
Asynchronous operations introduce a common lifecycle problem:
await something();
ref.watch(...);
The Provider may have been disposed while the asynchronous operation was waiting.
Riverpod 3 provides:
ref.mounted
to check whether the Ref is still active.
For example:
@riverpod
Future<String> example(Ref ref) async {
final result = await fetchData();
if (!ref.mounted) {
throw StateError(
'Provider was disposed',
);
}
return result;
}
This is particularly useful for long-running asynchronous workflows.
17. Testing
Riverpod 3 provides ProviderContainer.test() specifically for testing Providers.
test(
'counter increments',
() {
final container =
ProviderContainer.test();
expect(
container.read(counterProvider),
0,
);
container
.read(counterProvider.notifier)
.increment();
expect(
container.read(counterProvider),
1,
);
},
);
ProviderContainer.test() handles disposal of the Container when the test finishes.
For asynchronous Providers, .future can be used to wait for the result:
final value = await container
.read(todosProvider.future);
The source recommends testing abstractions such as Repositories rather than directly mocking Notifiers.
18. Offline Persistence
Riverpod 3 also includes Offline Persistence, which is currently an experimental feature.
The concept is that a Provider can persist state to a database and restore it when the application starts again.
A simplified architecture looks like this:
flowchart TD
Provider --> Memory
Provider --> Storage
Storage --> SQLite
SQLite --> Application
Persistence is opt-in. Riverpod does not require a specific database implementation, but provides an interface for connecting external storage, along with a SQLite package maintained by the Riverpod project.
19. Mutations
Riverpod 3 also introduces Mutations, an experimental API for managing side effects.
The basic idea is to separate:
State
from:
Mutation / Side Effect
For example:
flowchart TD
A[User presses Save button] --> B[Mutation]
B --> C[API request]
C --> D[Success]
C --> E[Error]
Mutations can be useful for operations such as:
- Create
- Update
- Delete
- Submit
- Upload
They provide a structured way for the UI to respond to the state of these operations.
Because Mutations are experimental, evaluate the API carefully before making it a foundation of a production architecture.
20. Provider Lifecycle in Riverpod 3
Riverpod 3 simplifies the Provider lifecycle API.
Earlier versions had several interfaces, such as:
Notifier
AutoDisposeNotifier
FamilyNotifier
AutoDisposeFamilyNotifier
Riverpod 3 brings these concepts together around Notifier, while lifecycle differences are handled through Provider behavior and configuration.
For example:
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
}
There is no longer a need to create a separate AutoDisposeNotifier for this pattern.
21. Legacy APIs
Riverpod 3 continues to support older APIs for compatibility, but they are available through:
import 'package:riverpod/legacy.dart';
Examples include:
StateProvider
StateNotifierProvider
ChangeNotifierProvider
These APIs have not been removed, but they are not the recommended approach for new code.
For new projects, start with:
Provider
Notifier
AsyncNotifier
StreamProvider / StreamNotifier
and use Code Generation where appropriate.
22. Recommended Architecture
For a medium-sized or large Flutter application, a structure like the following can work well:
lib/
├── app/
│ ├── app.dart
│ └── router.dart
│
├── core/
│ ├── network/
│ └── storage/
│
├── features/
│ └── todos/
│ ├── data/
│ │ └── todo_repository.dart
│ │
│ ├── domain/
│ │ └── todo.dart
│ │
│ └── presentation/
│ ├── todo_page.dart
│ └── todos_provider.dart
│
└── main.dart
The dependency flow can be represented as:
flowchart TD UI --> Provider[Riverpod Provider] Provider --> Notifier Notifier --> Repository Repository --> API Repository --> Database
Riverpod therefore does more than store state. It connects State → Business Logic → Dependencies.
23. Key Principles
Do not use Riverpod for every variable in your application.
For example:
bool isExpanded = false;
may simply be local UI state and does not necessarily need to become a Provider.
However, state such as:
Current User
Authentication
Shopping Cart
Todos
API Data
Application Settings
is often application state that can benefit from Riverpod.
A simple rule of thumb is:
flowchart TD
A{State belongs only to one Widget?} -->|Yes| B[Local State]
C{State is shared across the application<br/>or contains Business Logic?} -->|Yes| D[Riverpod]
24. The Riverpod 3 Mental Model
Ultimately, what matters is not memorizing every API, but understanding the mental model:
flowchart TD
Provider --> State[State / Data]
State --> Notifier
Notifier --> Read[read]
Notifier --> Watch[watch]
Notifier --> Listen[listen]
Read --> Action
Watch --> UI
Listen --> Effect
For asynchronous applications:
flowchart TD AsyncNotifier --> AsyncValue[AsyncValue<T>] AsyncValue --> Loading AsyncValue --> Data AsyncValue --> Error
And at the application architecture level:
flowchart TD UI[Flutter UI] --> Provider[Provider / Notifier] Provider --> Logic[Business Logic] Logic --> Repository Repository --> API Repository --> Database Repository --> Cache
Conclusion
Riverpod 3 provides a more centralized and consistent approach to state management in Flutter, particularly around Notifier, AsyncNotifier, unified Ref, and Code Generation.
For a new project, a useful starting architecture is:
flowchart TD Provider --> Notifier Notifier --> AsyncNotifier AsyncNotifier --> Repository Repository --> DataSources[API / Database]
At the UI level:
ref.watch(provider)
is used to observe state.
ref.read(provider.notifier)
is used to perform actions.
ref.listen(provider, ...)
is used for side effects.
Meanwhile, AsyncValue provides a unified state model for Loading, Data, and Error.
Riverpod 3 also introduces capabilities such as Automatic Retry, Ref.mounted, Offline Persistence, and Mutations. Some features, including Offline Persistence and Mutations, are still experimental and should be evaluated carefully before becoming core parts of a production architecture.
The most important principle is not to use as many Providers as possible. Instead, clearly identify which state belongs to the UI, which state belongs to the application, and where business logic should live.