05 · Riverpod¶
Riverpod was written by the author of provider to fix the things that bite in larger apps: providers that
can't be found because they're in the wrong part of the widget tree (ProviderNotFoundException), awkward
dependencies between providers, and async loading states every widget re-implements. Its central idea is that
providers are global declarations, but their state lives in a container, not in the widget tree. This
lesson was run with flutter_riverpod 3.4.3. Riverpod's 2.x → 3.x migration changed several APIs (and older
tutorials use StateNotifierProvider and StateProvider, now considered legacy), so match examples to your
installed major version.
The provider types you'll actually use¶
| Declaration | State | Typical use |
|---|---|---|
Provider<T>((ref) => ...) |
a computed value | dependencies (a repository, an HTTP client), derived data |
NotifierProvider<N, T>(N.new) |
synchronous T, changed by methods on N |
UI state: filters, selection, form state |
AsyncNotifierProvider<N, T>(N.new) |
AsyncValue<T> loaded in build(), then changed by methods |
data from a backend that the user edits |
FutureProvider<T> / StreamProvider<T> |
AsyncValue<T> |
read-only async data |
Add .autoDispose to any of them to destroy the state when nothing is listening (a details page's data,
for example), and .family to parameterize a provider by an argument (userProvider(42)).
A todo list with loading, filtering and derived state¶
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ---------- data layer ----------
class Todo {
const Todo(this.id, this.title, {this.done = false});
final int id;
final String title;
final bool done;
Todo toggled() => Todo(id, title, done: !done);
}
abstract class TodoRepository {
Future<List<Todo>> fetch();
}
class SlowFakeRepository implements TodoRepository {
@override
Future<List<Todo>> fetch() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return const [Todo(1, 'Write lesson'), Todo(2, 'Run tests', done: true)];
}
}
// ---------- providers ----------
/// A plain Provider: a dependency other providers can read. Tests override it.
final repositoryProvider = Provider<TodoRepository>((ref) => SlowFakeRepository());
/// AsyncNotifier: loads asynchronously, then exposes methods that change the state.
class TodoList extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() => ref.watch(repositoryProvider).fetch();
void toggle(int id) {
final current = state.value;
if (current == null) return;
state = AsyncData([for (final t in current) t.id == id ? t.toggled() : t]);
}
void add(String title) {
final current = state.value ?? const [];
final nextId = current.fold(0, (m, t) => t.id > m ? t.id : m) + 1;
state = AsyncData([...current, Todo(nextId, title)]);
}
}
final todoListProvider = AsyncNotifierProvider<TodoList, List<Todo>>(TodoList.new);
enum Filter { all, open, done }
/// Synchronous state with a Notifier.
class FilterNotifier extends Notifier<Filter> {
@override
Filter build() => Filter.all;
void set(Filter f) => state = f;
}
final filterProvider = NotifierProvider<FilterNotifier, Filter>(FilterNotifier.new);
/// Derived state: recomputes when either input changes.
final visibleTodosProvider = Provider<AsyncValue<List<Todo>>>((ref) {
final filter = ref.watch(filterProvider);
return ref.watch(todoListProvider).whenData((todos) => switch (filter) {
Filter.all => todos,
Filter.open => todos.where((t) => !t.done).toList(),
Filter.done => todos.where((t) => t.done).toList(),
});
});
final openCountProvider = Provider<int>(
(ref) => ref.watch(todoListProvider).value?.where((t) => !t.done).length ?? 0,
);
// ---------- UI ----------
class TodoPage extends ConsumerWidget {
const TodoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(visibleTodosProvider);
final open = ref.watch(openCountProvider);
return Scaffold(
appBar: AppBar(title: Text('Todos ($open open)')),
body: switch (todos) {
AsyncData(:final value) => ListView(children: [
for (final t in value)
CheckboxListTile(
title: Text(t.title),
value: t.done,
onChanged: (_) => ref.read(todoListProvider.notifier).toggle(t.id),
),
]),
AsyncError(:final error) => Center(child: Text('Failed: $error')),
_ => const Center(child: CircularProgressIndicator()),
},
bottomNavigationBar: SegmentedButton<Filter>(
segments: const [
ButtonSegment(value: Filter.all, label: Text('All')),
ButtonSegment(value: Filter.open, label: Text('Open')),
ButtonSegment(value: Filter.done, label: Text('Done')),
],
selected: {ref.watch(filterProvider)},
onSelectionChanged: (s) => ref.read(filterProvider.notifier).set(s.single),
),
);
}
}
The structure to notice:
repositoryProvideris the dependency seam. Nothing else constructs aSlowFakeRepository; tests swap it.TodoList.build()is where async loading happens. Itref.watches the repository provider — if that provider ever changed, the list would rebuild automatically. While theFutureis pending, the provider's state isAsyncLoading; thenAsyncDataorAsyncError.- Methods replace
statewith a new immutable value; Riverpod notifies listeners when you assign it. There's nonotifyListeners()to forget. visibleTodosProviderandopenCountProviderare derived. They watch other providers and recompute when those change — no manual syncing between "the list" and "the filtered list".ConsumerWidgetgets aWidgetRef:ref.watchinbuildto subscribe,ref.readin callbacks to call methods — the same split as Provider'swatch/read.- The
switchoverAsyncData/AsyncError/ loading uses Dart 3 pattern matching onAsyncValue's sealed class hierarchy, so the compiler checks you handled the cases.
The whole app is wrapped once: runApp(const ProviderScope(child: MyApp())). That ProviderScope owns the
container holding every provider's state.
Tests: with and without widgets¶
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:l2/s/riverpod_demo.dart';
class InstantRepo implements TodoRepository {
InstantRepo(this.items);
final List<Todo> items;
@override
Future<List<Todo>> fetch() async => items;
}
class FailingRepo implements TodoRepository {
@override
Future<List<Todo>> fetch() async => throw StateError('offline');
}
void main() {
test('providers without widgets: ProviderContainer', () async {
final container = ProviderContainer.test(overrides: [
repositoryProvider.overrideWithValue(InstantRepo(const [Todo(1, 'a'), Todo(2, 'b')])),
]);
print('before load: ${container.read(todoListProvider)}');
await container.read(todoListProvider.future);
print('open after load: ${container.read(openCountProvider)}');
container.read(todoListProvider.notifier)
..toggle(1)
..add('c');
container.read(filterProvider.notifier).set(Filter.open);
print('visible open: ${container.read(visibleTodosProvider).value!.map((t) => t.title).toList()}');
print('open count: ${container.read(openCountProvider)}');
});
testWidgets('real repository: spinner, then data', (tester) async {
await tester.pumpWidget(const ProviderScope(child: MaterialApp(home: TodoPage())));
print('spinner: ${find.byType(CircularProgressIndicator).evaluate().length}');
await tester.pump(const Duration(milliseconds: 300));
print('rows: ${find.byType(CheckboxListTile).evaluate().length}, title: ${tester.widget<Text>(find.textContaining('open)')).data}');
await tester.tap(find.text('Write lesson'));
await tester.pump();
print('after toggle: ${tester.widget<Text>(find.textContaining('open)')).data}');
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
print('done filter rows: ${find.byType(CheckboxListTile).evaluate().length}');
});
testWidgets('error state via override', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [repositoryProvider.overrideWithValue(FailingRepo())],
retry: (retryCount, error) => null, // don't retry in this test
child: const MaterialApp(home: TodoPage()),
));
await tester.pump();
print(tester.widget<Text>(find.textContaining('Failed')).data);
});
}
$ flutter test test/s/riverpod_test.dart
before load: AsyncLoading<List<Todo>>()
open after load: 2
visible open: [b, c]
open count: 2
spinner: 1
rows: 2, title: Todos (1 open)
after toggle: Todos (0 open)
done filter rows: 2
Failed: Bad state: offline
00:00 +3: All tests passed!
- No widgets needed.
ProviderContainer.test()creates a container (disposed automatically at the end of the test). OverridingrepositoryProvidermade loading instant; then toggling, adding and filtering were all checked by reading providers directly. Togglingato done and addingcleft[b, c]open. - The real (slow) repository showed a spinner on the first frame; after pumping 300 ms of fake time, two rows.
Toggling "Write lesson" updated the AppBar count —
openCountProviderrecomputed becausetodoListProviderchanged. The "Done" filter then showed both rows (both were done by then). - The error path was forced with an override. Riverpod 3 automatically retries providers that fail (with
backoff) by default, which is great for flaky networks but would make this test wait; passing a
retryfunction that returnsnulldisables it for this scope.
Riverpod versus Provider¶
Both are fine. Riverpod's advantages show up as an app grows: compile-time-safe access to any provider from
anywhere (no ProviderNotFoundException), dependencies between providers expressed with ref.watch, built-in
AsyncValue, automatic disposal, and testing by override. The costs: more concepts up front, and API changes
between majors. There's also an optional code-generation style (@riverpod annotations with
riverpod_generator) that writes the provider declarations for you; it's a matter of taste — this course uses the
manual declarations so you can see exactly what exists.
How It Actually Works¶
A provider object like todoListProvider is an immutable recipe: it holds the function that creates the state,
not the state itself. The state lives in a ProviderContainer (ProviderScope creates one and exposes it through
an InheritedWidget — so Riverpod still uses the mechanism from lesson 03, just once at the root).
The first time something reads a provider, the container creates an element for it and runs build. Every
ref.watch made during build is recorded as an edge in a dependency graph. When a provider's state changes, the
container walks its dependents: derived providers are marked dirty and recomputed lazily the next time they're
read, and ConsumerWidgets that watched them are scheduled to rebuild. If a recomputed value is == to the
previous one, the propagation stops there. autoDispose providers are destroyed when their listener count drops
to zero; overrides simply tell the container to use a different recipe for a given provider.
Common mistakes¶
ref.watchinside callbacks (onPressed). Useref.readthere;watchbelongs inbuild.ref.readinbuildto display state — the widget won't update.- Mutating state in place (
state.value!.add(todo)) — the reference didn't change, so nothing is notified. Always assign a new value. - Forgetting
ProviderScopeat the root: the app throws on the firstref.watch. - Storing everything in one giant notifier. Split by responsibility and derive the rest.
- Copying 2.x examples into a 3.x app (or vice versa). Check the major version first.
Exercise¶
- Add a text field and button that call
add. Write a container test proving new todos get unique ids. - Make a
todoProvider = Provider.family<Todo?, int>that returns one todo by id, and a details page that watches it. Use.autoDisposeand verify (withref.onDisposeand a print) when it's destroyed. - Make
toggleoptimistic: update the UI immediately, call a (fake)repository.save, and roll back with an error message if it throws. - Port lesson 04's cart to Riverpod. Which Provider-era concepts map to which Riverpod ones?