State that flows,
UI that reacts.
An interactive walk through Riverpod, the state management library that quietly fixed everything that was awkward about Provider. We'll start from the mental model, build up the folder structure, then play with live demos until the abstractions click.
First, unlearn setState.
In Flutter, the moment your app stops being a single screen, setState turns into a leaky bucket. State gets duplicated, lifted, prop-drilled, then forgotten in the wrong widget. Riverpod's whole job is to take state out of the widget tree and put it somewhere addressable.
You hold state in StatefulWidgets, then pass callbacks down and values up through constructors. When two distant widgets need the same data, you "lift state up" to a common ancestor and pray the tree doesn't refactor.
Tightly coupled to the widget tree. Hard to test. Rebuilds are coarse.
State lives in globally addressable containers called providers. Any widget can ask for it via ref.watch(provider), and Riverpod handles subscribing, rebuilding, and disposing.
Decoupled from the tree. Trivial to test. Rebuilds are surgical.
The three vocabulary words
- Provider: a declaration. "Here is a piece of state, and here is how to compute it." Providers don't do anything until something asks for them.
- Ref: a remote control. Inside a widget or another provider, ref lets you read, watch, listen to, or invalidate other providers.
- ProviderScope: the container that holds all your providers' state. You wrap your app in it once, at the root, and forget it exists.
void main() { runApp( const ProviderScope( // the container child: MyApp(), ), ); }
That single wrapper is the entire setup. The same line whether you use code-gen or classic syntax.
Where does everything live?
Your preferred shape: feature-first MVC with a repository layer added in. Each feature is a self-contained vertical slice with four folders, models, repo, controllers, views. Shared infrastructure (theme, router, the Dio client) lives in core/. Features depend on core/, never on each other.
Watch data flow through the layers
Hit "Trigger fetch". A request travels down through your layers to the network, then a response travels back up. Each layer lights up as it does its job.
The folder tree, annotated
Click any colored folder to see what belongs there and how it maps to MVC.
Pick any folder in the tree
Hover over the colored folder names on the left to learn what kind of code lives in each layer, and how it maps to the MVC parts you already know.
One concept, many flavors.
A provider is just a function that returns a value. The flavor of provider you pick depends on what shape that value is. You declare each one by hand, a single final line, no build step. (Code generation can write that line for you, but it's optional; the toggle shows it where you want it.)
Setup: what goes in pubspec
The base install is small. You only add the code-gen packages if you decide to opt into @riverpod later, they're not needed for hand-written providers.
# pubspec.yaml dependencies: flutter_riverpod: ^2.5.1 dev_dependencies: riverpod_lint: ^2.3.10 custom_lint: ^0.6.4 # --- only if you opt into code generation --- # dependencies: # riverpod_annotation: ^2.3.5 # dev_dependencies: # build_runner: ^2.4.9 # riverpod_generator: ^2.4.0
The four shapes of state
All four, side by side
Flip the syntax toggle in the top bar to see the same providers in either form.
// 1. A plain value (Provider) @riverpod Dio apiClient(Ref ref) { return Dio(BaseOptions(baseUrl: 'https://api.example.com')); } // 2. A one-shot async value (FutureProvider) @riverpod Future<User> currentUser(Ref ref) async { final client = ref.watch(apiClientProvider); final res = await client.get('/me'); return User.fromJson(res.data); } // 3. A continuous stream (StreamProvider) @riverpod Stream<int> tick(Ref ref) { return Stream.periodic( const Duration(seconds: 1), (i) => i, ); } // 4. Mutable state with methods (Notifier) @riverpod class Counter extends _$Counter { @override int build() => 0; void increment() => state++; void reset() => state = 0; }
// 1. A plain value (Provider) final apiClientProvider = Provider<Dio>((ref) { return Dio(BaseOptions(baseUrl: 'https://api.example.com')); }); // 2. A one-shot async value (FutureProvider) final currentUserProvider = FutureProvider<User>((ref) async { final client = ref.watch(apiClientProvider); final res = await client.get('/me'); return User.fromJson(res.data); }); // 3. A continuous stream (StreamProvider) final tickProvider = StreamProvider<int>((ref) { return Stream.periodic( const Duration(seconds: 1), (i) => i, ); }); // 4. Mutable state with methods (Notifier) class Counter extends Notifier<int> { @override int build() => 0; void increment() => state++; void reset() => state = 0; } final counterProvider = NotifierProvider<Counter, int>(Counter.new);
The plain version is one explicit final line: you can read the value type and notifier type right there, and there's no generated file to keep in sync. The code-gen version saves you that line at the cost of a build step. For most providers the line is trivial to write, which is why this guide leaves it hand-written. The one case where code-gen pulls real weight is multi-argument families, see Chapter 8.
When is code-gen worth it?
Code generation is never required, everything in this guide works as plain Dart. But it isn't useless either. Here's a straight cost/benefit so you can opt in deliberately rather than by default.
Classic .family takes exactly one argument. Need two (say userId and includeArchived)? You're wrapping them in a record or a custom class with ==/hashCode. Code-gen just lets you write a second parameter. This is the strongest case.
For a plain Provider, FutureProvider, or single-arg family, the hand-written line is shorter than the annotation plus the part directive, and it doesn't need build_runner watch running in a terminal. Reaching for code-gen here is pure overhead.
A reasonable middle path some teams adopt: hand-write everything by default, and switch a single file to code-gen only when that file needs a multi-arg family. The two styles coexist fine in the same project, the toggle in this guide is essentially that, file by file.
Which provider should I actually use?
Three quick questions about your data, and I'll point you to the right provider with a code snippet to copy. The recommendation also respects your syntax toggle.
A counter, end to end.
Tap the buttons. Watch three things at once: the value on the device, the widget tree (only the watching widget rebuilds), and the console (showing what Riverpod is doing under the hood).
// features/counter/controllers/counter_controller.dart @riverpod class Counter extends _$Counter { @override int build() => 0; void increment() => state++; void decrement() => state--; void reset() => state = 0; } // features/counter/views/counter_screen.dart class CounterScreen extends ConsumerWidget { @override Widget build(BuildContext ctx, WidgetRef ref) { // Subscribes: rebuilds when count changes final count = ref.watch(counterProvider); // One-shot lookup of the notifier (methods only) final ctrl = ref.read(counterProvider.notifier); return Column(children: [ Text('$count'), Row(children: [ ElevatedButton( onPressed: ctrl.decrement, child: const Text('-'), ), ElevatedButton( onPressed: ctrl.increment, child: const Text('+'), ), ]), ]); } }
// features/counter/controllers/counter_controller.dart class Counter extends Notifier<int> { @override int build() => 0; void increment() => state++; void decrement() => state--; void reset() => state = 0; } final counterProvider = NotifierProvider<Counter, int>(Counter.new); // features/counter/views/counter_screen.dart class CounterScreen extends ConsumerWidget { @override Widget build(BuildContext ctx, WidgetRef ref) { // Subscribes: rebuilds when count changes final count = ref.watch(counterProvider); // One-shot lookup of the notifier final ctrl = ref.read(counterProvider.notifier); return Column(children: [ Text('$count'), Row(children: [ ElevatedButton( onPressed: ctrl.decrement, child: const Text('-'), ), ElevatedButton( onPressed: ctrl.increment, child: const Text('+'), ), ]), ]); } }
What just happened
- The button's onPressed called ctrl.increment(), which mutated state inside the Notifier.
- Riverpod notified every widget that ref.watched this provider, only the Text in this case.
- The rest of the tree (Column, Row, both Buttons) didn't rebuild. That's the surgical rebuild promise in action.
Loading, data, error: all three at once.
Real apps talk to backends. Riverpod's answer to "this value isn't here yet" is AsyncValue<T>, a sum type that's always in exactly one of three states. Below, watch the state machine light up as you trigger the demo.
// features/profile/repo/user_repository.dart (REPOSITORY) class UserRepository { UserRepository(this._ref); final Ref _ref; Future<User> fetchUser(String id) async { final client = _ref.read(apiClientProvider); final res = await client.get('/users/$id'); return User.fromJson(res.data); } } @riverpod UserRepository userRepository(Ref ref) => UserRepository(ref); // features/profile/controllers/profile_controller.dart (CONTROLLER) @riverpod Future<User> profile(Ref ref, String userId) async { final repo = ref.watch(userRepositoryProvider); return repo.fetchUser(userId); }
// features/profile/repo/user_repository.dart (REPOSITORY) class UserRepository { UserRepository(this._ref); final Ref _ref; Future<User> fetchUser(String id) async { final client = _ref.read(apiClientProvider); final res = await client.get('/users/$id'); return User.fromJson(res.data); } } final userRepositoryProvider = Provider<UserRepository>((ref) { return UserRepository(ref); }); // features/profile/controllers/profile_controller.dart (CONTROLLER) final profileProvider = FutureProvider.family<User, String>((ref, userId) async { final repo = ref.watch(userRepositoryProvider); return repo.fetchUser(userId); });
Why pass Ref instead of the client itself?
Notice the repository holds a Ref, not a Dio. The class can then reach for any provider it needs lazily, without listing dependencies up front. When the repo grows new responsibilities later (a logger, a cache, an analytics client), you just call _ref.read(someOtherProvider) inside the method that needs it. No constructor edits, no provider rewires.
Trade-off worth knowing: the class becomes Riverpod-aware, so tests use a ProviderContainer with overrides instead of passing concrete mocks to the constructor. In practice that's fine, often easier, since you override providers once per test group.
// features/profile/views/profile_screen.dart class ProfileScreen extends ConsumerWidget { const ProfileScreen({ required this.userId, super.key, }); final String userId; @override Widget build(BuildContext ctx, WidgetRef ref) { final async = ref.watch(profileProvider(userId)); // .when() forces you to handle ALL three states. // Exhaustive on purpose. No forgotten spinners. return async.when( loading: () => const CircularProgressIndicator(), error: (e, st) => Text('Something broke: $e'), data: (user) => UserCard(user: user), ); } } // Pull-to-refresh? Just invalidate. ref.invalidate(profileProvider(userId)); // Provider re-runs. UI goes loading → data again.
The three AsyncValue states
- loading: the Future hasn't completed yet. Show a spinner, skeleton, or shimmer.
- data: the Future resolved with a value. Render it.
- error: the Future threw. Show a message, log to Sentry, offer a retry button.
Bonus pattern: async.when(skipLoadingOnRefresh: true, ...) keeps the previous data on screen while a refresh runs in the background. Perfect for pull-to-refresh.
Values that keep arriving.
A Future resolves once. A Stream resolves over and over. For anything live, a websocket feed, Firestore snapshots, a forex tick, you want a StreamProvider. Same AsyncValue contract, except now the data branch fires every time a new value arrives.
// features/trading/repo/price_feed.dart class PriceFeed { Stream<double> forSymbol(String symbol) { return _socket .subscribe(symbol) .map((event) => event.price); } } @riverpod Stream<double> priceStream(Ref ref, String symbol) { final feed = ref.watch(priceFeedProvider); return feed.forSymbol(symbol); } // features/trading/views/ticker_screen.dart class TickerScreen extends ConsumerWidget { @override Widget build(BuildContext ctx, WidgetRef ref) { final async = ref.watch( priceStreamProvider('XAU/USD'), ); return async.when( loading: () => const Text('waiting...'), error: (e, _) => Text('$e'), data: (price) => Text('\$$price'), ); } }
// features/trading/repo/price_feed.dart class PriceFeed { Stream<double> forSymbol(String symbol) { return _socket .subscribe(symbol) .map((event) => event.price); } } final priceStreamProvider = StreamProvider.family<double, String>((ref, symbol) { final feed = ref.watch(priceFeedProvider); return feed.forSymbol(symbol); }); // features/trading/views/ticker_screen.dart class TickerScreen extends ConsumerWidget { @override Widget build(BuildContext ctx, WidgetRef ref) { final async = ref.watch( priceStreamProvider('XAU/USD'), ); return async.when( loading: () => const Text('waiting...'), error: (e, _) => Text('$e'), data: (price) => Text('\$$price'), ); } }
Riverpod handles the subscribe/unsubscribe lifecycle for you. When no widget is watching anymore, the stream is closed. When the first widget subscribes again, it reopens.
Watch, read, listen, invalidate.
ref is your remote control. Picking the right method is 80% of writing good Riverpod code. The wrong choice means either too many rebuilds, or a widget that silently goes stale.
| Method | When to use |
|---|---|
| ref.watch(p) | The default. Inside build() or another provider's body. Subscribes and rebuilds on change. If in doubt, use this. |
| ref.read(p) | Inside callbacks (button onPressed, gestures). One-shot, no subscription. Never call from build() directly: you'll get stale data. |
| ref.listen(p, cb) | For side effects when a value changes: navigation, snackbars, dialogs, analytics. The callback fires, no rebuild required. |
| ref.invalidate(p) | "Throw away the cached value and recompute next time someone watches." Perfect for pull-to-refresh or logout. |
| ref.refresh(p) | Like invalidate, but also returns the new value immediately. Useful when you need to await the fresh result. |
class LoginScreen extends ConsumerWidget { @override Widget build(BuildContext ctx, WidgetRef ref) { // 1. WATCH: subscribe + rebuild on change final auth = ref.watch(authControllerProvider); // 2. LISTEN: react to changes with side effects, no rebuild ref.listen<AsyncValue<User?>>(authControllerProvider, (prev, next) { next.whenOrNull( data: (user) { if (user != null) ctx.go('/home'); }, error: (e, _) => ScaffoldMessenger.of(ctx) .showSnackBar(SnackBar(content: Text('$e'))), ); }); return ElevatedButton( onPressed: () { // 3. READ: one-shot call from a callback ref.read(authControllerProvider.notifier).signIn(email, pw); }, child: const Text('Sign in'), ); } }
The rule of thumb
watch in build() when the UI depends on the value. read in callbacks when you just need to fire a method. listen in build() when you need to navigate or show something but don't render the value directly.
.family and .autoDispose.
Almost everything else in Riverpod is a variation on these two ideas. In plain Dart they're explicit: .family and .autoDispose are methods you chain onto the provider type, so you can see exactly what's switched on. (Code-gen infers both from your function signature instead.)
When a provider needs an argument, "give me the user with this id", you make it a family.
// Just add a parameter: @riverpod Future<User> user(Ref ref, String id) { return ref.watch(repoProvider).fetch(id); } // Use it: ref.watch(userProvider('u_123'));
// Explicit .family on the type: final userProvider = FutureProvider.family<User, String>((ref, id) { return ref.watch(repoProvider).fetch(id); }); // Use it: ref.watch(userProvider('u_123'));
In plain Dart, a provider lives forever unless you chain .autoDispose onto it, so a screen-local search query opts in, while a global API client just stays. (Code-gen flips this: it auto-disposes by default and you opt out with keepAlive: true.)
@riverpod // auto-disposes by default class SearchQuery extends _$SearchQuery { @override String build() => ''; void set(String q) => state = q; } @Riverpod(keepAlive: true) Dio apiClient(Ref ref) => Dio();
// Explicit .autoDispose chain: class SearchQuery extends AutoDisposeNotifier<String> { @override String build() => ''; void set(String q) => state = q; } final searchQueryProvider = NotifierProvider .autoDispose<SearchQuery, String>(SearchQuery.new); // Classic = keepAlive by default: final apiClientProvider = Provider<Dio>((ref) => Dio());
Heads up: the default direction flips between the two styles. Code-gen is auto-dispose by default, opt out with keepAlive: true. Classic is keep-alive by default, opt in with .autoDispose.
Flavors & environments.
Dev, staging, prod: different API URLs, different keys, different app names. There are two layers to this. The native build flavor (Gradle product flavors on Android, schemes on iOS) controls bundle IDs, launcher icons, and which google-services file ships. Riverpod owns the other layer: injecting the right Dart-side config into your providers. It doesn't replace native flavors, it complements them.
The mechanism is the one capability of ProviderScope we hadn't used yet: overrides. You declare a config provider that refuses to run on its own, then override it with a real value at each flavor's entry point. Forget to override it and the app fails loudly at startup, which is exactly what you want.
Step 1 — describe a flavor
An enum and an immutable config class. Plain data, lives in core/ since any feature may read it.
// core/config/app_config.dart enum Flavor { dev, staging, prod } class AppConfig { const AppConfig({ required this.flavor, required this.appName, required this.baseUrl, required this.sentryDsn, }); final Flavor flavor; final String appName; final String baseUrl; final String sentryDsn; bool get isProd => flavor == Flavor.prod; }
Step 2 — a provider that must be overridden
Here's the idiom: the provider throws by default. It has no sensible value until a flavor supplies one, so it makes that a hard error rather than a silent wrong default.
// core/config/app_config_provider.dart final appConfigProvider = Provider<AppConfig>((ref) { throw UnimplementedError( 'appConfigProvider was not overridden. ' 'Launch from a main_<flavor>.dart entry point.', ); });
Step 3 — one entry point per flavor
Each flavor gets its own main_*.dart. They share MyApp; they differ only in the override they hand to ProviderScope. Click the three below: each "boots" the app with that override and shows what every provider downstream now sees, and the code pane swaps to that flavor's entry point.
Step 4 — everything else just reads it
This is the payoff. Remember apiClientProvider from the earlier chapters? Its baseUrl was hard-coded. Now it reads the flavor config, so the same code points at dev, staging, or prod depending only on which entry point you launched.
// core/network/api_client.dart final apiClientProvider = Provider<Dio>((ref) { final config = ref.watch(appConfigProvider); return Dio(BaseOptions(baseUrl: config.baseUrl)); });
No widget and no repository knows which flavor it's in. They ask for apiClientProvider or appConfigProvider and Riverpod hands them the value the entry point injected. Swapping environments becomes a launch argument, not a code change.
Launching a flavor
Pair the Dart entry point with the matching native flavor in one command:
# dev flutter run --flavor dev -t lib/main_dev.dart # staging flutter run --flavor staging -t lib/main_staging.dart # production flutter run --flavor prod -t lib/main_prod.dart
You could reach for a global const flavor = Flavor.dev or an --dart-define. The override approach wins on testing: a widget test wraps the widget in a ProviderScope with a fake AppConfig and exercises prod-only behavior without touching any global state. It's the same lever the entry points pull, reused in tests.
Mistakes everyone makes.
A short list of things that bit most people in their first month. Skim now so you recognize them when they happen.
Calling ref.read inside build()
You'll get the current value once, then never again. The widget goes stale and looks broken. Rule: watch in build, read in callbacks.
Mutating state in place
If your state is a List or a Map, doing state.add(x) won't trigger a rebuild. You must reassign: state = [...state, x]. Riverpod checks identity, not contents.
Putting business logic in widgets
If your ConsumerWidget.build is doing API calls, validation, or transformation, that belongs in a Notifier or repository. The widget should read and render, period.
Forgetting to add the lint rules
Install riverpod_lint and enable custom_lint. It catches missing ConsumerWidgets, forgotten ref.watches, and other footguns at write-time.
Reaching for a Notifier when you don't need one
If your state is derived from other providers, you don't need a Notifier. A plain @riverpod function that reads from other providers gives you reactive derived state for free.