Flutter: State Management — Riverpod
State is the lifeblood of an app — mismanaged, it causes "internal bleeding": data inconsistency, rebuild storms, memory leaks.
📋 Prerequisites: You need to master the following first
- Lesson 5: StatefulWidget and Interaction
- Lesson 11: Networking and REST API
1. What You Will Learn
- Provider system: Provider / StateProvider / FutureProvider / StreamProvider / NotifierProvider
- Riverpod 2.x: Notifier / AsyncNotifier / @riverpod annotation code generation
- ref.watch / ref.read / ref.listen usage scenarios and performance trade-offs
- ProviderScope and ConsumerWidget / ConsumerStatefulWidget
- ShopApp: CartNotifier for shopping cart + AsyncProductNotifier for async product loading
2. A True Story of a State Disaster
(1) Pain Point: The Global State Dilemma with setState
Bob's ShopApp uses setState to manage shopping cart state. The problem: cart data is needed on the Home, Detail, and Cart pages, requiring callbacks to pass it through every layer of the widget tree. After CartPage changes the quantity, the AppBar badge on HomePage doesn't update — because HomePage's State doesn't know what CartPage changed. Worse, 10 pages hold 5 copies of cart data, all out of sync.
(2) The Riverpod Solution
Riverpod lifts state to global Providers. Any page can ref.watch the same cart state, and a change in one place automatically updates all listeners.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod riverpod_annotation
// ⚙️ Dev dependency: flutter pub add --dev riverpod_generator build_runner
// Custom class definition sources:
// - Product: see Lesson 11 json_serializable model
// - CartItem: see CartNotifier example below
// Global cart state - any page can access
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void addItem(Product product) {
state = [...state, CartItem(product: product, quantity: 1)];
}
void removeItem(int productId) {
state = state.where((item) => item.product.id != productId).toList();
}
}
// Watch in any widget
final cart = ref.watch(cartNotifierProvider);
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
(3) Benefit: One Change, Global Sync
After using Riverpod, Bob's cart state lives in a single place — any page's changes automatically update all listeners. Callback hell disappears, and data stays consistent.
3. Riverpod Core Concepts
graph TD
PS[ProviderScope] --> CN[CartNotifier]
PS --> PN[ProductAsyncNotifier]
PS --> UN[UserNotifier]
CN --> |ref.watch| CV[CartView]
PN --> |ref.watch| PV[ProductListView]
UN --> |ref.watch| UV[UserProfileView]
CN --> |totalItems| Badge[BottomNav Badge]
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
(1) Provider Types
| Provider | State Type | Use Case |
|---|---|---|
Provider |
Immutable value | Dependency injection, configuration |
StateProvider |
Simple mutable value | Counter, toggle |
FutureProvider |
Future result | One-time async data |
StreamProvider |
Stream result | Real-time data stream |
NotifierProvider |
Notifier instance | Complex state + business logic |
AsyncNotifierProvider |
AsyncNotifier | Async initialization + business logic |
(2) ref Method Comparison
| Method | Purpose | Triggers Rebuild | Where to Use |
|---|---|---|---|
ref.watch |
Listen to state changes | Yes (when state changes) | Inside build method |
ref.read |
One-time read | No | Callbacks / event handlers |
ref.listen |
Listen + execute side effects | No | Inside build (navigation/SnackBar) |
4. Hand-Written Providers
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
: StateProvider simple state
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod
// Simple counter with StateProvider
final counterProvider = StateProvider<int>((ref) => 0);
// Theme mode provider
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
// Usage in widget
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: const Text('Increment'),
),
],
);
}
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
: FutureProvider async data
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod
// Custom class definition sources:
// - Product: see Lesson 11 json_serializable model
// - ProductTile: custom Widget for displaying a single product
// - productRepositoryProvider: see Lesson 14 ProductRepository
// Load products once
final productsProvider = FutureProvider<List<Product>>((ref) async {
final repo = ref.read(productRepositoryProvider);
return repo.getProducts();
});
// Usage
class ProductList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return productsAsync.when(
loading: () => const CircularProgressIndicator(),
error: (err, _) => Text('Error: $err'),
data: (products) => ListView.builder(
itemCount: products.length,
itemBuilder: (_, i) => ProductTile(product: products[i]),
),
);
}
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
5. Notifier and AsyncNotifier
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
: CartNotifier (complete shopping cart logic)
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod
// Custom class definition sources:
// - Product: see Lesson 11 json_serializable model (simplified version below)
/*
class Product {
final int id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
*/
class CartItem {
final Product product;
final int quantity;
const CartItem({required this.product, required this.quantity});
double get total => product.price * quantity;
CartItem copyWith({int? quantity}) => CartItem(product: product, quantity: quantity ?? this.quantity);
}
class CartNotifier extends Notifier<List<CartItem>> {
@override
List<CartItem> build() => [];
void addItem(Product product) {
final idx = state.indexWhere((i) => i.product.id == product.id);
if (idx >= 0) {
state = [...state]..[idx] = state[idx].copyWith(quantity: state[idx].quantity + 1);
} else {
state = [...state, CartItem(product: product, quantity: 1)];
}
}
void updateQuantity(int productId, int quantity) {
if (quantity <= 0) {
removeItem(productId);
return;
}
state = [
for (final item in state)
if (item.product.id == productId) item.copyWith(quantity: quantity) else item,
];
}
void removeItem(int productId) {
state = state.where((i) => i.product.id != productId).toList();
}
void clear() => state = [];
}
final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>(CartNotifier.new);
// Derived state: total price
final cartTotalProvider = Provider<double>((ref) {
final items = ref.watch(cartProvider);
return items.fold(0.0, (sum, item) => sum + item.total);
});
// Derived state: item count
final cartItemCountProvider = Provider<int>((ref) {
final items = ref.watch(cartProvider);
return items.fold(0, (sum, item) => sum + item.quantity);
});
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
: AsyncNotifier (async product initialization)
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod
// Custom class definition sources:
// - Product: see Lesson 11 json_serializable model
// - productRepositoryProvider: see Lesson 14 ProductRepository
class ProductAsyncNotifier extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
final repo = ref.read(productRepositoryProvider);
return repo.getProducts();
}
Future<void> loadMore() async {
final current = state.valueOrNull ?? [];
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final more = await ref.read(productRepositoryProvider).getProducts(page: (current.length ~/ 20) + 1);
return [...current, ...more];
});
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(productRepositoryProvider).getProducts());
}
}
final productProvider = AsyncNotifierProvider<ProductAsyncNotifier, List<Product>>(
ProductAsyncNotifier.new,
);
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
6. @riverpod Code Generation
▶ Example
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
: Using riverpod_generator
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod riverpod_annotation
// ⚙️ Dev dependency: flutter pub add --dev riverpod_generator build_runner
// Custom class definition sources:
// - CartItem: see Lesson 5 CartNotifier example in this lesson
// - User/AuthService: see Lesson 14 Auth Notifier
part 'providers.g.dart';
// Generated notifier
@riverpod
class Cart extends _$Cart {
@override
List<CartItem> build() => [];
void addItem(Product product) {
final idx = state.indexWhere((i) => i.product.id == product.id);
if (idx >= 0) {
state[idx] = state[idx].copyWith(quantity: state[idx].quantity + 1);
state = [...state]; // Trigger rebuild
} else {
state = [...state, CartItem(product: product, quantity: 1)];
}
}
}
// Generated provider (derived)
@riverpod
double cartTotal(CartTotalRef ref) {
final items = ref.watch(cartProvider);
return items.fold(0.0, (sum, item) => sum + item.total);
}
// Keep alive across widget lifecycle
@Riverpod(keepAlive: true)
class Auth extends _$Auth {
@override
AsyncValue<User?> build() => const AsyncData(null);
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => AuthService.login(email, password));
}
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
7. Complete Example: ShopApp Riverpod Integration
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
// ⚙️ Install dependency: flutter pub add flutter_riverpod go_router
// Custom class definition sources:
// - Product: see Lesson 11 json_serializable model
// - cartProvider/cartTotalProvider/cartItemCountProvider: see Lesson 5 in this lesson
// - themeModeProvider: see Lesson 4 StateProvider in this lesson
// main.dart
void main() {
runApp(ProviderScope(child: ShopApp()));
}
class ShopApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'ShopApp',
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
darkTheme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true, brightness: Brightness.dark),
themeMode: themeMode,
routerConfig: router,
);
}
}
// Cart badge in AppBar - auto-updates
class CartBadge extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartItemCountProvider);
return Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.shopping_cart),
if (count > 0)
Positioned(right: 0, top: 0,
child: CircleAvatar(radius: 8, backgroundColor: Colors.red,
child: Text('$count', style: const TextStyle(fontSize: 9, color: Colors.white)))),
],
);
}
}
// Add to cart action
class AddToCartButton extends ConsumerWidget {
final Product product;
const AddToCartButton({super.key, required this.product});
@override
Widget build(BuildContext context, WidgetRef ref) {
return FilledButton.icon(
onPressed: () {
ref.read(cartProvider.notifier).addItem(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} added to cart')),
);
},
icon: const Icon(Icons.shopping_cart),
label: const Text('Add to Cart'),
);
}
}
// Cart page with Riverpod
class CartPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(cartProvider);
final total = ref.watch(cartTotalProvider);
return Scaffold(
appBar: AppBar(title: Text('Cart (${items.length})')),
body: items.isEmpty
? const Center(child: Text('Cart is empty'))
: Column(children: [
Expanded(child: ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (_, i) {
final item = items[i];
return ListTile(
title: Text(item.product.name),
subtitle: Text('\$${item.total.toStringAsFixed(2)}'),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(icon: const Icon(Icons.remove), onPressed: () =>
ref.read(cartProvider.notifier).updateQuantity(item.product.id, item.quantity - 1)),
Text('${item.quantity}'),
IconButton(icon: const Icon(Icons.add), onPressed: () =>
ref.read(cartProvider.notifier).updateQuantity(item.product.id, item.quantity + 1)),
]),
);
},
)),
Padding(padding: const EdgeInsets.all(16),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('\$${total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
FilledButton(onPressed: () => context.push('/checkout'), child: const Text('Checkout')),
])),
]),
);
}
}
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.
❓ FAQ
.when(loading:, error:, data:) pattern matching to display loading indicators, error pages, and data content respectively.dart run build_runner watch.📖 Summary
- Riverpod uniformly manages global state — one change updates all listeners automatically
- Notifier suits complex state + business logic; AsyncNotifier suits async initialization
- ref.watch listens in build, ref.read operates in callbacks, ref.listen handles side effects
- Derived Providers (cartTotalProvider) auto-compute dependent state
- @riverpod code generation reduces boilerplate — recommended for new projects
📝 Exercises
- Basic (⭐): Use StateProvider to implement dark mode switching, and display the current mode in the AppBar with ConsumerWidget.
- Intermediate (⭐⭐): Use Notifier to implement CartNotifier with add/remove/update and total price calculation, displaying cart state on two different pages.
- Challenge (⭐⭐⭐): Use @riverpod code generation to implement Auth + Product + Cart Notifiers: after login, auto-load products; after adding to cart, badge auto-updates.