Let's be honest: Flutter state management has been a mess.
setState? Too simple for real apps.
BLoC? Too much boilerplate.
Provider? Better, but confusing.
Redux? Welcome to ceremony hell.
Then Riverpod arrived and said: "What if state management was actually... pleasant?"
Riverpod (an anagram of "Provider," get it?) is Flutter's modern state management solution. No BuildContext dependency. Compile-time safety. Easy testing. Zero boilerplate with code generation. It's become the go-to pattern in modern mobile app state management across Flutter projects.
Think of state management like managing mpesa transactions at a busy shop. setState is writing everything in one notebook (chaos). Riverpod is having a proper system - different counters for different services, instant notifications when money moves, automatic receipts. Everything organized, everything reactive.
Today we're mastering Riverpod 3.0 (released September 2025) with real-world examples you'll actually use.
Setup: Add Riverpod to Your Flutter Project
pubspec.yaml:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.0.0 # Core Riverpod for Flutter
dev_dependencies:
riverpod_generator: ^3.0.0 # Code generation (optional but recommended)
build_runner: ^2.4.0 # Required for code generation
riverpod_lint: ^3.0.0 # Linting rules
Run:
flutter pub get
Core Concepts: Providers Explained
Providers are objects that encapsulate state and expose it to your UI. Think of them as smart variables that notify listeners when they change.
1. Provider (Immutable Value)
For values that never change:
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Simple value
final apiUrlProvider = Provider<String>((ref) {
return 'https://api.myapp.com';
});
// Computed value
final greetingProvider = Provider<String>((ref) {
final hour = DateTime.now().hour;
if (hour < 12) return 'Good morning!';
if (hour < 18) return 'Good afternoon!';
return 'Good evening!';
});
2. StateProvider (Mutable Simple State)
For simple state that changes (like a counter):
// Counter that can be incremented/decremented
final counterProvider = StateProvider<int>((ref) => 0);
// Theme mode toggle
final isDarkModeProvider = StateProvider<bool>((ref) => false);
3. FutureProvider (Async Data)
For async operations that complete once:
// Fetch user profile
final userProvider = FutureProvider<User>((ref) async {
final api = ref.watch(apiServiceProvider);
return await api.getUser();
});
// Fetch weather data
final weatherProvider = FutureProvider<Weather>((ref) async {
final response = await http.get(Uri.parse('https://api.weather.com/current'));
return Weather.fromJson(jsonDecode(response.body));
});
4. StreamProvider (Continuous Updates)
For streams of data:
// Real-time location updates
final locationProvider = StreamProvider<Position>((ref) {
return Geolocator.getPositionStream();
});
// Firestore real-time updates
final messagesProvider = StreamProvider<List<Message>>((ref) {
return FirebaseFirestore.instance
.collection('messages')
.orderBy('timestamp', descending: true)
.snapshots()
.map((snapshot) => snapshot.docs.map((doc) => Message.fromFirestore(doc)).toList());
});
5. StateNotifierProvider (Complex State Logic)
For complex state with custom logic:
// State class
class Counter extends StateNotifier<int> {
Counter() : super(0);
void increment() => state++;
void decrement() => state--;
void reset() => state = 0;
}
// Provider
final counterProvider = StateNotifierProvider<Counter, int>((ref) {
return Counter();
});
Real-World Example: Todo App with Riverpod
Let's build a complete todo app with add, toggle, filter, and delete functionality.
1. Models:
// lib/models/todo.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'todo.freezed.dart';
part 'todo.g.dart';
@freezed
class Todo with _$Todo {
const factory Todo({
required String id,
required String title,
@Default(false) bool completed,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
enum TodoFilter { all, active, completed }
2. State Management:
// lib/providers/todo_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/todo.dart';
// Todo list state
class TodoList extends StateNotifier<List<Todo>> {
TodoList() : super([]);
void add(String title) {
state = [
...state,
Todo(
id: DateTime.now().toString(),
title: title,
),
];
}
void toggle(String id) {
state = [
for (final todo in state)
if (todo.id == id)
todo.copyWith(completed: !todo.completed)
else
todo,
];
}
void remove(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
// Providers
final todoListProvider = StateNotifierProvider<TodoList, List<Todo>>((ref) {
return TodoList();
});
final todoFilterProvider = StateProvider<TodoFilter>((ref) => TodoFilter.all);
// Computed/filtered todos
final filteredTodosProvider = Provider<List<Todo>>((ref) {
final filter = ref.watch(todoFilterProvider);
final todos = ref.watch(todoListProvider);
switch (filter) {
case TodoFilter.all:
return todos;
case TodoFilter.active:
return todos.where((todo) => !todo.completed).toList();
case TodoFilter.completed:
return todos.where((todo) => todo.completed).toList();
}
});
// Stats
final uncompletedTodosCountProvider = Provider<int>((ref) {
final todos = ref.watch(todoListProvider);
return todos.where((todo) => !todo.completed).length;
});
3. UI - Main Screen:
// lib/screens/todo_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/todo_providers.dart';
import '../models/todo.dart';
class TodoScreen extends ConsumerWidget {
const TodoScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(filteredTodosProvider);
final uncompletedCount = ref.watch(uncompletedTodosCountProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Riverpod Todos'),
actions: [
// Stats badge
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'$uncompletedCount items left',
style: const TextStyle(fontSize: 14),
),
),
),
],
),
body: Column(
children: [
// Add todo input
const TodoInput(),
// Filter tabs
const TodoFilterTabs(),
// Todo list
Expanded(
child: todos.isEmpty
? const Center(child: Text('No todos yet!'))
: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoItem(todo: todos[index]);
},
),
),
],
),
);
}
}
4. UI - Components:
// Add todo input
class TodoInput extends ConsumerStatefulWidget {
const TodoInput({Key? key}) : super(key: key);
@override
ConsumerState<TodoInput> createState() => _TodoInputState();
}
class _TodoInputState extends ConsumerState<TodoInput> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _addTodo() {
if (_controller.text.trim().isEmpty) return;
ref.read(todoListProvider.notifier).add(_controller.text.trim());
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'What needs to be done?',
suffixIcon: IconButton(
icon: const Icon(Icons.add),
onPressed: _addTodo,
),
),
onSubmitted: (_) => _addTodo(),
),
);
}
}
// Filter tabs
class TodoFilterTabs extends ConsumerWidget {
const TodoFilterTabs({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(todoFilterProvider);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_FilterButton(
label: 'All',
isSelected: filter == TodoFilter.all,
onTap: () => ref.read(todoFilterProvider.notifier).state = TodoFilter.all,
),
_FilterButton(
label: 'Active',
isSelected: filter == TodoFilter.active,
onTap: () => ref.read(todoFilterProvider.notifier).state = TodoFilter.active,
),
_FilterButton(
label: 'Completed',
isSelected: filter == TodoFilter.completed,
onTap: () => ref.read(todoFilterProvider.notifier).state = TodoFilter.completed,
),
],
);
}
}
class _FilterButton extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: TextButton.styleFrom(
foregroundColor: isSelected ? Colors.blue : Colors.grey,
textStyle: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
child: Text(label),
);
}
}
// Todo item
class TodoItem extends ConsumerWidget {
final Todo todo;
const TodoItem({Key? key, required this.todo}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: Key(todo.id),
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
direction: DismissDirection.endToStart,
onDismissed: (_) {
ref.read(todoListProvider.notifier).remove(todo.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${todo.title} deleted')),
);
},
child: ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
ref.read(todoListProvider.notifier).toggle(todo.id);
},
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed ? TextDecoration.lineThrough : null,
color: todo.completed ? Colors.grey : null,
),
),
),
);
}
}
5. Main App Setup:
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'screens/todo_screen.dart';
void main() {
runApp(
// Wrap entire app with ProviderScope
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod Todo',
theme: ThemeData(primarySwatch: Colors.blue),
home: const TodoScreen(),
);
}
}
Advanced Patterns
1. Async Data with Loading States
class UserRepository {
Future<User> fetchUser(String id) async {
await Future.delayed(const Duration(seconds: 2)); // Simulate API call
return User(id: id, name: 'John Kamau', email: 'john@example.com');
}
}
final userRepositoryProvider = Provider((ref) => UserRepository());
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final repository = ref.watch(userRepositoryProvider);
return await repository.fetchUser(userId);
});
// In UI
class UserProfile extends ConsumerWidget {
final String userId;
const UserProfile({Key? key, required this.userId}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider(userId));
return userAsync.when(
data: (user) => Column(
children: [
Text(user.name, style: const TextStyle(fontSize: 24)),
Text(user.email),
],
),
loading: () => const CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
);
}
}
2. Refresh and Invalidate
// In UI - Pull to refresh
RefreshIndicator(
onRefresh: () async {
// Invalidate provider to refetch
ref.invalidate(userProvider);
},
child: ListView(...),
)
// Or manually refresh
ElevatedButton(
onPressed: () {
ref.refresh(userProvider(userId));
},
child: const Text('Refresh'),
)
3. Provider Dependencies
// API service provider
final apiServiceProvider = Provider((ref) => ApiService());
// Auth provider that depends on API service
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
final api = ref.watch(apiServiceProvider);
return AuthNotifier(api);
});
// User provider that depends on auth
final currentUserProvider = FutureProvider<User?>((ref) async {
final authState = ref.watch(authProvider);
if (authState is Authenticated) {
final api = ref.watch(apiServiceProvider);
return await api.getCurrentUser();
}
return null;
});
4. Code Generation (Recommended for Production)
// lib/providers/counter_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter_provider.g.dart';
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
// Generate code:
// flutter pub run build_runner build
Testing with Riverpod
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
test('Counter increments correctly', () {
final container = ProviderContainer();
addTearDown(container.dispose);
// Initial value
expect(container.read(counterProvider), 0);
// Increment
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
// Increment again
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 2);
});
test('Filtered todos work correctly', () {
final container = ProviderContainer();
addTearDown(container.dispose);
// Add todos
container.read(todoListProvider.notifier).add('Buy milk');
container.read(todoListProvider.notifier).add('Walk dog');
// All todos
expect(container.read(filteredTodosProvider).length, 2);
// Complete one
final firstTodo = container.read(todoListProvider)[0];
container.read(todoListProvider.notifier).toggle(firstTodo.id);
// Filter completed
container.read(todoFilterProvider.notifier).state = TodoFilter.completed;
expect(container.read(filteredTodosProvider).length, 1);
// Filter active
container.read(todoFilterProvider.notifier).state = TodoFilter.active;
expect(container.read(filteredTodosProvider).length, 1);
});
}
Best Practices
1. Use ConsumerWidget instead of StatelessWidget:
// ✅ DO
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myProvider);
// ...
}
}
2. Read vs Watch:
// watch - rebuilds when provider changes (use in build)
final count = ref.watch(counterProvider);
// read - one-time read (use in callbacks/methods)
onPressed: () => ref.read(counterProvider.notifier).increment()
3. Dispose resources:
final databaseProvider = Provider((ref) {
final database = Database();
ref.onDispose(() {
database.close();
});
return database;
});
Common Mistakes
❌ Using ref.read in build method
// Wrong - won't rebuild on changes
final count = ref.read(counterProvider);
✅ Use ref.watch in build
final count = ref.watch(counterProvider);
❌ Not wrapping app with ProviderScope
runApp(MyApp()); // Crash!
✅ Always wrap with ProviderScope
runApp(ProviderScope(child: MyApp()));
The Bottom Line
Riverpod makes state management in Flutter actually pleasant:
- No BuildContext dependency
- Compile-time safety
- Easy testing
- Zero boilerplate with code generation
- Great DevTools integration
When deciding between mobile-first or web-first development, Riverpod's architecture works beautifully for both Flutter mobile and Flutter web. Pair it with strong Dart null safety patterns to eliminate runtime null errors, and remember that state architecture should support good user experience, not fight against it.
You've seen how to:
- Use different provider types
- Build a complete todo app
- Handle async data
- Test providers
- Follow best practices
State management doesn't have to be painful. With Riverpod, it's just... manageable.
Now go build something stateful!
Sources: