Architecture Field Guides
Syntax:
LLM rule View .md
Riverpod 2.x · Flutter State Management

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.

Stack
Flutter + Riverpod 2.x
Pattern
Plain Dart providers
Reading time
~30 minutes

Syntax toggle. This guide defaults to plain hand-written providers, no build step, no generated files. Code generation is never required in Riverpod; it's just sugar over what you see here. Flip the toggle to @riverpod (code-gen) on any snippet to see the generated-equivalent, useful for reading other people's code or for the one place it genuinely earns its keep (multi-argument families, covered in Chapter 8).

Chapter 01 / The mental model

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.

Without Riverpod
State lives inside widgets

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.

With Riverpod
State lives in providers

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

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.

Chapter 02 / Code architecture

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.

view views/
ConsumerWidget calls ref.watch(profileProvider)
controller controllers/
Notifier calls repo.fetchUser(id)
repository repo/
Reads Dio from core/network, hits the endpoint, parses into a Model
external API · external
Server returns JSON. The trip down ends here, the trip back begins.
request down · response up

The folder tree, annotated

Click any colored folder to see what belongs there and how it maps to MVC.

// lib/ main.dart src/ core/ │ ├ theme/ │ ├ router/ │ ├ network/ // Dio client provider │ └ utils/ features/ auth/ │ ├ models/ // MODEL: data classes │ │ └ user.dart │ ├ repo/ // data access │ │ └ auth_repository.dart │ ├ controllers/ // CONTROLLER: notifiers │ │ └ auth_controller.dart │ └ views/ // VIEW: screens │ ├ login_screen.dart │ └ widgets/ todos/ models/ │ └ todo.dart repo/ │ └ todos_repository.dart controllers/ │ └ todos_controller.dart views/ todos_screen.dart widgets/
click a folder →

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.

Chapter 03 / Providers, the building block

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

Provider
Synchronous, read-only value
A derived value, a constant, or a singleton service like an API client. No mutation.
FutureProvider
One-shot async value
For things you fetch once: a user profile, today's weather. Returns an AsyncValue<T>.
StreamProvider
Continuous async values
For things that change over time: a Firestore document, a websocket feed, a tick of the clock.
Notifier / AsyncNotifier
Mutable state with methods
Your "controller". State plus the methods that change it. The workhorse you'll use most.

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.

Chapter 03.4 / A quick honest take

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.

Worth it
Multi-argument families

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.

Not worth it
A simple provider

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.

Chapter 03.5 / Decision aid

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.

Chapter 04 / Notifier in action

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).

counter_demo · live
counter screen
0
WIDGET TREE
CounterScreen ConsumerWidget
Column
  └Text("$count") watches counter
  └Row
    └Button("−")
    └Button("+")
00:00 [provider] counterProvider initialized · state = 0
// 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

Chapter 05 / Async values

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);
});
Pattern note · DI style

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.

future_provider_demo · live
loading
data
/
error
tap fetch to begin
00:00 [provider] waiting for first read...
// 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

Bonus pattern: async.when(skipLoadingOnRefresh: true, ...) keeps the previous data on screen while a refresh runs in the background. Perfect for pull-to-refresh.

Chapter 06 / Streams

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.

stream_provider_demo · live
XAU/USD · spot
2,418.50
+0.00
00:00 [stream] priceStreamProvider · not subscribed
// 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.

Chapter 07 / The Ref methods

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.

MethodWhen 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.

Chapter 08 / Two modifiers worth knowing

.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.)

family
Parameterized providers

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'));
autoDispose
Garbage collect when unused

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.

Chapter 08.5 / Applied pattern

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.

flavor_boot · live
DEV
MyApp Dev
flavor Flavor.dev
baseUrl https://dev.api.example.com
Sentry off
00:00 [scope] pick a flavor to boot

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
Why overrides beat a global

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.

Chapter 09 / Common pitfalls

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.

Pitfall 01

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.

Pitfall 02

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.

Pitfall 03

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.

Pitfall 04

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.

Pitfall 05

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.