Skip to content
Blog

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(&quot;alice&quot;)<br/>Alice"]
    Provider --> Bob["userProvider(&quot;bob&quot;)<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.

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&lt;T&gt;]
  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.