Riverpod #
The Riverpod library is a modern architectural evolution of the Provider system, specifically designed to solve various fundamental limitations of its predecessor. By removing the absolute dependence on BuildContext, Riverpod offers very high compile-time safety, allows independent multiple instantiation of the same provider type, and provides very declarative async status handling through the AsyncValue concept. This flexibility makes Riverpod one of the main state management standards most recommended for medium-to-large commercial Flutter app development today.
Installation #
To use Riverpod in your Flutter app, add the following dependency packages to your project’s pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
# Riverpod integration package for Flutter
flutter_riverpod: ^2.6.1
dev_dependencies:
# Used to run the code generator (optional, but highly recommended)
build_runner: ^2.4.13
ProviderScope Setup #
For all the state from your providers to be stored and accessed safely in memory, you must wrap the app’s root widget with ProviderScope. Behind the scenes, ProviderScope acts as the storage for a global ProviderContainer object.
void main() {
runApp(
// ProviderScope must be placed at the very top of the widget tree
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: DashboardScreen(),
);
}
}
Riverpod’s State Storage Architecture #
One of Riverpod’s biggest advantages is its architecture that completely separates data status storage from the UI structure (widget tree).
flowchart TD
ProviderScope["ProviderScope (Root Widget)"] --> Container["ProviderContainer (Global Memory Store)"]
Container --> ProviderA["ProviderA: ApiService\n(State: Instantiated)"]
Container --> ProviderB["ProviderB: AuthNotifier\n(State: UserLoggedIn)"]
Container --> ProviderC["ProviderC: CartNotifier\n(State: 3 items)"]
WidgetTree["Widget Tree (Render Layer)"] -.-|Context-Free / Fetch Directly via Ref| ContainerBy separating data status storage memory from the widget tree rendering, you can easily read, manipulate, and test your app’s state from pure Dart files without needing to simulate a UI rendering environment.
Provider Types in Riverpod #
Riverpod provides various special provider types tailored to the data scenarios you face:
1. Provider (Immutable / Read-Only Value) #
Suitable for holding static service or repository instantiations whose values won’t change throughout the app’s running lifecycle.
// Defining a static ApiService
final apiServiceProvider = Provider<ApiService>((ref) {
return ApiService(baseUrl: 'https://api.example.com');
});
// Connecting inter-provider dependencies instantly without ProxyProvider
final productRepositoryProvider = Provider<ProductRepository>((ref) {
// ref.watch dynamically tracks apiServiceProvider changes
final apiService = ref.watch(apiServiceProvider);
return ProductRepository(api: apiService);
});
2. Notifier (Mutable Synchronous State) #
Notifier is Riverpod’s modern reactive class for managing synchronous data status that requires structured state mutation logic through special methods.
@immutable
class CollectionStatus {
final List<String> nameList;
const CollectionStatus({this.nameList = const []});
CollectionStatus copyWith({List<String>? nameList}) {
return CollectionStatus(nameList: nameList ?? this.nameList);
}
}
// Creating a custom Notifier class
class CollectionNotifier extends Notifier<CollectionStatus> {
// build() must be overridden to set the initial state value
@override
CollectionStatus build() {
return const CollectionStatus();
}
void addItem(String name) {
// state represents the current status value
state = state.copyWith(nameList: [...state.nameList, name]);
}
void removeItem(String name) {
state = state.copyWith(
nameList: state.nameList.where((item) => item != name).toList(),
);
}
}
// Declaring the NotifierProvider globally
final collectionProvider = NotifierProvider<CollectionNotifier, CollectionStatus>(
CollectionNotifier.new,
);
3. AsyncNotifier (Async State with Mutation Control) #
AsyncNotifier is the best solution for managing data status obtained through async processes (like HTTP Requests to API servers) while still needing to write business logic actions for data updates.
class ProductNotifier extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
// Reading the repository asynchronously
final repo = ref.watch(productRepositoryProvider);
return repo.fetchAllProducts();
}
Future<void> addProduct(Product newProduct) async {
// 1. Get the repository reference
final repo = ref.read(productRepositoryProvider);
// 2. Set temporary status to loading or do an optimistic update
state = const AsyncLoading();
// 3. Run the action and update status based on the result
state = await AsyncValue.guard(() async {
await repo.saveProduct(newProduct);
// Fetch the latest data to stay in sync
return repo.fetchAllProducts();
});
}
}
final productProvider = AsyncNotifierProvider<ProductNotifier, List<Product>>(
ProductNotifier.new,
);
4. FutureProvider (Simple Async Loading) #
Used when you only need to load data from a Future once passively without needing manual mutations afterward.
final newsDetailProvider = FutureProvider.autoDispose.family<News, String>(
(ref, newsId) async {
final repo = ref.watch(productRepositoryProvider);
return repo.fetchNewsDetail(newsId);
},
);
Interacting Using the Ref Object #
To read state in Riverpod, you use the WidgetRef object inside widgets, or Ref inside other provider classes. There are three main interaction methods:
1. ref.watch() (Listening to Changes & Rebuild) #
The ref.watch() method observes the status value of a specific provider and triggers a reconstruction (rebuild) on the calling widget every time that data status updates.
- Rule: Always use
ref.watch()inside your widget’sbuildfunction.
class CollectionListWidget extends ConsumerWidget {
const CollectionListWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Subscribing to collection data changes
final collection = ref.watch(collectionProvider);
return ListView(
children: collection.nameList.map((name) => Text(name)).toList(),
);
}
}
2. ref.read() (Read Once Without Rebuild) #
The ref.read() method instantly fetches the current status value without registering the widget as an active listener.
- Rule: Use it only inside event callback functions like button clicks or initial initialization; never call it directly inside the build method.
ElevatedButton(
onPressed: () {
// Calling a Notifier method without triggering a rebuild on this button
ref.read(collectionProvider.notifier).addItem('New Item');
},
child: const Text('Add Data'),
)
3. ref.listen() (Triggering Side Effects) #
The ref.listen() method is used to listen to status changes and execute certain procedural actions (like showing a SnackBar, opening a new page via Navigator, or triggering a warning dialog) without triggering a UI rebuild.
class DashboardWidget extends ConsumerWidget {
const DashboardWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Listening for error status to trigger a SnackBar reactively
ref.listen<CollectionStatus>(collectionProvider, (CollectionStatus? prev, CollectionStatus next) {
if (next.nameList.length > 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Your collection is full!')),
);
}
});
return const Center(child: Text('Active Content'));
}
}
AsyncValue — Declaratively Handling Three Async States #
Every time you read an async provider (like FutureProvider or AsyncNotifierProvider), Riverpod returns an AsyncValue<T> object. This concept is a sealed class wrapping three possible data states structurally:
- AsyncLoading: The async process is running.
- AsyncData: Data was successfully fetched with data type
T. - AsyncError: An async error occurred during the loading process.
This concept forces you to safely handle all three scenarios at the UI level using the .when() function:
class ProductListScreen extends ConsumerWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Reading the product async status
final productsAsync = ref.watch(productProvider);
return Scaffold(
appBar: AppBar(title: const Text('Product Catalog')),
body: productsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) => Center(
child: Text('An error occurred: $error'),
),
data: (List<Product> productList) {
return ListView.builder(
itemCount: productList.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(productList[index].name),
);
},
);
},
),
);
}
}
family — Sending Dynamic Parameters to Providers #
The .family modifier is used when you need a provider whose value is dynamic based on specific input parameters (like an entity ID).
// Defining a provider with a String ID parameter
final catalogDetailProvider = FutureProvider.family<Product, String>(
(ref, catalogId) async {
final repo = ref.watch(productRepositoryProvider);
return repo.fetchDetail(catalogId);
},
);
// How to access it in the UI:
class DetailWidget extends ConsumerWidget {
final String productId;
const DetailWidget({super.key, required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Riverpod automatically identifies and caches a separate instance for this productId
final productAsync = ref.watch(catalogDetailProvider(productId));
return productAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, s) => Text('Error: $e'),
data: (product) => Text(product.name),
);
}
}
autoDispose — Smart Memory Management #
In production-scale apps, it’s important to keep RAM usage from ballooning. The .autoDispose modifier instructs Riverpod to automatically destroy that provider’s state and free its memory as soon as there are no longer any active widgets on screen listening to it.
flowchart TD
Start["Widget Starts Listening to Provider"] --> Create["Trigger provider build()"]
Create --> InUse["Provider in Memory"]
InUse --> WidgetDestroyed["Widget Destroyed / No Listeners"]
WidgetDestroyed --> CheckAutoDispose{"Is it using\n.autoDispose?"}
CheckAutoDispose -->|Yes| KeepAliveActive{"Was ref.keepAlive()\ncalled?"}
CheckAutoDispose -->|No| KeepInMemory["Stays in Memory"]
KeepAliveActive -->|Yes| KeepInMemory
KeepAliveActive -->|No| Destroy["Destroy State & Free Memory"]If in certain scenarios you need the async data from a provider using .autoDispose to stay temporarily in memory (e.g., so users don’t need to reload data when returning to a previous page), you can use ref.keepAlive() to retain that data in a controlled way.
final newsDataProvider = FutureProvider.autoDispose<List<News>>((ref) async {
final repo = ref.watch(productRepositoryProvider);
final data = await repo.fetchLatestNews();
// Retaining the cache status dynamically
final link = ref.keepAlive();
// You can close keepAlive based on a timer or custom action if needed
ref.onDispose(() {
link.close();
});
return data;
});
ConsumerWidget vs ConsumerStatefulWidget #
To integrate Riverpod reactivity into the interface, you replace Flutter’s standard widget base classes:
- ConsumerWidget: The replacement for
StatelessWidget. TheWidgetRef refparameter is injected directly into thebuild()method signature. - ConsumerStatefulWidget: The replacement for
StatefulWidget. Therefobject is globally available as a class property in its companionConsumerStateclass (so you can accessreffrom lifecycle methods likeinitStateordidChangeDependencies).
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final TextEditingController _queryController = TextEditingController();
@override
void initState() {
super.initState();
// You can use ref directly in lifecycle methods!
ref.read(collectionProvider.notifier).addItem('Search Started');
}
@override
void dispose() {
_queryController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// ref is accessed as a class member field, no need to pass it as a build parameter
final status = ref.watch(collectionProvider);
return Scaffold(
appBar: AppBar(title: const Text('Search')),
body: Column(
children: [
TextField(controller: _queryController),
Expanded(
child: ListView(
children: status.nameList.map((name) => Text(name)).toList(),
),
),
],
),
);
}
}
Recommended Feature-Based Folder Structure #
In large-scale apps, it’s recommended to organize code files using a feature-oriented approach (Feature-First Architecture):
lib/
├── main.dart
├── core/ # Global logic & shared utilities
│ └── network/
│ └── api_provider.dart
└── features/ # Self-contained feature modules
├── catalog/
│ ├── data/
│ │ └── catalog_repository.dart
│ ├── domain/
│ │ └── product_model.dart
│ └── presentation/
│ ├── catalog_screen.dart
│ └── catalog_provider.dart # Defines catalog Notifiers & Providers
└── transaction/
├── data/
├── domain/
└── presentation/
Summary #
- Context-Free: Riverpod’s architecture stores state in global memory (
ProviderContainer), freeing business logic from dependence onBuildContext.- AsyncValue: A concept guaranteeing safe handling of loading, data, and error states across all async transactions at the UI level declaratively.
- Method Choices: Always call
ref.watch()in build methods for reactivity,ref.read()in event callback functions for one-time actions, andref.listen()to execute side effects.- RAM Optimization: Leverage the
.autoDisposeproperty combined withref.keepAlive()to automate the cleanup of unused memory states.- Supporting Widgets: Use
ConsumerWidgetfor fast static widgets, andConsumerStatefulWidgetif you need lifecycle method handling.