This article gets straight to the code — no lengthy theory. Using the old Provider package and finding state management increasingly messy? Or starting a new project and unsure what to pick? Riverpod 2.x solves both. Let’s dive in.
Get Started: Install Riverpod and Run in 5 Minutes
Add the dependencies to pubspec.yaml:
dependencies:
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.3
build_runner: ^2.4.9
Wrap your entire app with ProviderScope — this step is mandatory; skip it and you’ll get an immediate crash:
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
Create your first provider and use it right away:
final counterProvider = StateProvider<int>((ref) => 0);
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: Text('Increment'),
),
],
);
}
}
ConsumerWidget instead of StatelessWidget — that’s the core difference from the old Provider package. Quick start done; let’s move on to more substantial content.
Provider Types and When to Use Each
StateProvider — Simple State
Stick to primitives: bool, int, String, enum. Don’t stuff a List or complex object in here — that’s what NotifierProvider is for:
final isDarkModeProvider = StateProvider<bool>((ref) => false);
final selectedTabProvider = StateProvider<int>((ref) => 0);
FutureProvider — API Calls and Async File Reads
I use this one most often when fetching data from a server — typically through a repository class to abstract the data layer (the Repository Pattern translates cleanly across languages and frameworks). The nice thing is it automatically handles loading/error state — no extra boilerplate needed:
final userListProvider = FutureProvider<List<User>>((ref) async {
final repo = ref.watch(userRepositoryProvider);
return repo.fetchAll();
});
// In the widget
class UserList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncUsers = ref.watch(userListProvider);
return asyncUsers.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (_, i) => ListTile(title: Text(users[i].name)),
),
loading: () => CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
}
}
NotifierProvider — Complex Business Logic
StateNotifierProvider was deprecated in Riverpod 2.x — NotifierProvider is its replacement. Simple rule: all state-handling logic lives here, not in your widgets:
class CartNotifier extends Notifier<List<CartItem>> {
@override
List<CartItem> build() => [];
void addItem(CartItem item) {
state = [...state, item];
}
void removeItem(String id) {
state = state.where((item) => item.id != id).toList();
}
double get total => state.fold(0, (sum, item) => sum + item.price);
}
final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>(
CartNotifier.new,
);
AsyncNotifierProvider — Async State with Side Effects
Login, form submission, checkout — scenarios that need to both load data and trigger side effects. AsyncNotifierProvider handles exactly this:
class AuthNotifier extends AsyncNotifier<User?> {
@override
Future<User?> build() async => null;
Future<void> login(String email, String password) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() =>
ref.read(authRepositoryProvider).login(email, password),
);
}
void logout() => state = const AsyncValue.data(null);
}
Advanced: Riverpod Generator — Write Less Code, Make Fewer Mistakes
I once refactored a 50K-line codebase, and the most expensive lesson was that you need solid test coverage before you start. Riverpod Generator helps enormously here — it auto-generates boilerplate, reduces refactoring risk, and makes intent far clearer than writing it by hand.
Run build_runner in watch mode throughout development:
dart run build_runner watch --delete-conflicting-outputs
Write providers with annotations; build_runner handles the rest:
// user_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_provider.g.dart'; // this file is auto-generated
@riverpod
Future<List<User>> userList(UserListRef ref) async {
return ref.watch(userRepositoryProvider).fetchAll();
}
// Provider with a parameter (replaces family)
@riverpod
Future<User> userById(UserByIdRef ref, String id) async {
return ref.watch(userRepositoryProvider).getById(id);
}
// Stateful provider using a class
@riverpod
class Cart extends _$Cart {
@override
List<CartItem> build() => [];
void add(CartItem item) => state = [...state, item];
void remove(String id) => state = state.where((i) => i.id != id).toList();
}
keepAlive — Cache Provider After Widget Disposal
By default with Generator, providers auto-dispose when they lose their last listener. Use keepAlive: true for data that needs caching, like config or user sessions:
@Riverpod(keepAlive: true)
Future<AppConfig> appConfig(AppConfigRef ref) async {
return ConfigService().load(); // Only loads once
}
Practical Tips from Production Projects
1. Override Providers in Tests — Why I Chose Riverpod
Compared to GetX or BLoC, testing with Riverpod is much cleaner. For end-to-end UI test automation on top of unit tests, Maestro makes Flutter UI testing automation surprisingly painless. No mock framework needed for unit tests. No bloated DI container:
testWidgets('displays user list', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userListProvider.overrideWith((ref) async => [
User(id: '1', name: 'Test User'),
]),
],
child: MaterialApp(home: UserList()),
),
);
await tester.pumpAndSettle();
expect(find.text('Test User'), findsOneWidget);
});
2. ref.invalidate() to Refresh Data
ElevatedButton(
onPressed: () async {
await ref.read(cartProvider.notifier).checkout();
ref.invalidate(orderHistoryProvider); // Force re-fetch
ref.invalidate(cartProvider); // Reset cart to initial state
},
child: Text('Checkout'),
)
3. Avoid watch in Callbacks — The Most Common Mistake
// ❌ Wrong — throws an exception immediately
ElevatedButton(
onPressed: () {
final user = ref.watch(userProvider); // NEVER watch inside a callback
},
);
// ✅ Correct — use read inside a callback
ElevatedButton(
onPressed: () {
final user = ref.read(userProvider);
},
);
4. Listen to Side Effects with listenManual
In ConsumerStatefulWidget, use ref.listenManual in initState to react to state changes without triggering a full widget rebuild:
@override
void initState() {
super.initState();
ref.listenManual(authProvider, (previous, next) {
// Navigate when auth state changes
if (next.valueOrNull == null) {
Navigator.pushReplacementNamed(context, '/login');
}
});
}
5. Organize Folders by Feature, Not by Type
lib/
features/
auth/
providers/ ← auth_provider.dart + auth_provider.g.dart
models/
repositories/
screens/
cart/
providers/
models/
...
This structure scales far better than dumping all providers into a single folder. Need to remove a feature? Delete the whole folder and you’re done — no hunting for scattered files across the project.
Still on the old Provider package? Migrate one feature at a time — don’t do a bulk migration. From that 50K-line refactor: tackle it in small pieces, keep tests thorough, and rolling back is 10x easier than rewriting everything at once and then having no idea where the bugs are coming from.

