Flutter State Management — Provider, Riverpod, and BLoC
In this tutorial, you will learn about Flutter State Management. We cover key concepts, practical examples, and best practices to help you master this topic.
Flutter state management solutions separate business logic from UI code by storing mutable state outside widgets and notifying listeners when state changes, enabling reactive updates.
What Will You Learn
- The problem state management solves
- Provider for Dependency Injection and state exposure
- Riverpod as a compile-safe Provider alternative
- BLoC pattern with events and states
- Comparing state management approaches
- When to use each solution
Why It Matters
As Flutter apps grow beyond simple counter demos, managing state with setState becomes impractical. State shared across screens, derived state, async state, and complex state transitions require a structured approach. Without proper state management, the widget tree becomes tangled with business logic, making the app hard to maintain and test. Provider is the recommended solution for most apps by the Flutter team, while Riverpod offers improvements and BLoC is popular in enterprise applications.
Real-World Use
The DodaTech Flutter app uses Riverpod for state management. A coursesProvider fetches course data from the API and caches it. A searchQueryProvider holds the current search text. A derived filteredCoursesProvider combines both providers and returns only matching courses. When the user types in the search bar, the filtered list updates reactively without manual setState calls.
Learning Path
flowchart LR A[Flutter Scrolling] --> B[State Management\nYou are here] B --> C[Flutter Navigation] style B fill:#f90,color:#fff
The State Management Problem
Without state management, widgets hold state and pass it down through constructors:
// Problem: state is scattered and hard to share
class ParentWidget extends StatefulWidget {
@override
State<ParentWidget> createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State<ParentWidget> {
int _counter = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('$_counter'),
ChildWidget(counter: _counter),
ElevatedButton(
onPressed: () => setState(() => _counter++),
child: Text('Increment'),
),
],
);
}
}
This works for simple cases but fails when: the counter needs to be shared with a deeply nested widget, persisted across screens, or derived from multiple sources. State management solutions solve these issues.
Provider
Provider exposes values to the widget tree via BuildContext. It is the officially recommended solution:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Model
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
// App setup
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterScreen(),
);
}
}
// Consumer widget
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Provider')),
body: Center(
child: Consumer<CounterModel>(
builder: (context, counter, child) {
return Text(
'Count: ${counter.count}',
style: TextStyle(fontSize: 48),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<CounterModel>().increment();
},
child: Icon(Icons.add),
),
);
}
}
ChangeNotifierProvider provides a CounterModel instance to the widget tree. Consumer<CounterModel> rebuilds when notifyListeners() is called. context.read<T>() accesses the provider without listening to changes.
MultiProvider
Use MultiProvider to expose multiple models:
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthModel()),
ChangeNotifierProvider(create: (_) => CartModel()),
ChangeNotifierProvider(create: (_) => SettingsModel()),
],
child: MyApp(),
),
);
}
Each ChangeNotifierProvider creates a separate model. Widgets consume only the providers they need.
Provider with Services
Provider handles dependency injection for services:
class ApiService {
Future<List<String>> fetchItems() async {
await Future.delayed(Duration(seconds: 1));
return ['Item 1', 'Item 2', 'Item 3'];
}
}
class ItemModel extends ChangeNotifier {
final ApiService _api;
List<String> _items = [];
bool _isLoading = false;
ItemModel(this._api);
List<String> get items => _items;
bool get isLoading => _isLoading;
Future<void> loadItems() async {
_isLoading = true;
notifyListeners();
_items = await _api.fetchItems();
_isLoading = false;
notifyListeners();
}
}
void main() {
runApp(
MultiProvider(
providers: [
Provider(create: (_) => ApiService()),
ChangeNotifierProxyProvider<ApiService, ItemModel>(
create: (context) => ItemModel(context.read<ApiService>()),
),
],
child: MyApp(),
),
);
}
ProxyProvider creates a model that depends on another provider. Provider (without ChangeNotifier) is used for plain objects like services.
Riverpod
Riverpod is a compile-safe, testable alternative to Provider:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Provider definition
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
void reset() => state = 0;
}
// App setup
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: CounterScreen());
}
}
// Consumer widget
class CounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: Text('Riverpod')),
body: Center(
child: Text('Count: $count', style: TextStyle(fontSize: 48)),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: Icon(Icons.add),
),
);
}
}
ProviderScope wraps the app. ConsumerWidget replaces StatelessWidget and provides WidgetRef. ref.watch() listens to changes and rebuilds the widget. ref.read() accesses state without listening.
Derived State with Riverpod
Riverpod supports derived state and async state natively:
final searchQueryProvider = StateProvider<String>((ref) => '');
final itemsProvider = FutureProvider<List<String>>((ref) async {
await Future.delayed(Duration(seconds: 1));
return ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape'];
});
final filteredItemsProvider = FutureProvider<List<String>>((ref) async {
final query = ref.watch(searchQueryProvider).toLowerCase();
final items = await ref.watch(itemsProvider.future);
if (query.isEmpty) return items;
return items.where((item) => item.toLowerCase().contains(query)).toList();
});
class SearchScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final filtered = ref.watch(filteredItemsProvider);
return Scaffold(
appBar: AppBar(title: Text('Search')),
body: Column(
children: [
TextField(
onChanged: (value) => ref.read(searchQueryProvider.notifier).state = value,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: Icon(Icons.search),
),
),
Expanded(
child: filtered.when(
data: (items) => ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => ListTile(title: Text(items[i])),
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error: $err')),
),
),
],
),
);
}
}
FutureProvider handles async operations with loading, data, and error states. Derived providers automatically recompute when their dependencies change. The search UI updates reactively as the user types.
BLoC Pattern
BLoC (Business Logic Component) uses streams for state management:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// Events
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
// State
class CounterState {
final int count;
CounterState(this.count);
}
// BLoC
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterState(0)) {
on<IncrementEvent>((event, emit) {
emit(CounterState(state.count + 1));
});
on<DecrementEvent>((event, emit) {
emit(CounterState(state.count - 1));
});
}
}
void main() {
runApp(
BlocProvider(
create: (_) => CounterBloc(),
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(home: CounterScreen());
}
}
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('BLoC')),
body: Center(
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Text('Count: ${state.count}', style: TextStyle(fontSize: 48));
},
),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(IncrementEvent()),
child: Icon(Icons.add),
),
SizedBox(height: 8),
FloatingActionButton(
onPressed: () => context.read<CounterBloc>().add(DecrementEvent()),
child: Icon(Icons.remove),
),
],
),
);
}
}
BLoC separates events (user actions) from state (UI data). BlocProvider provides the bloc. BlocBuilder rebuilds the widget on state changes. BLoC is testable because events and state are plain objects.
Choosing the Right Solution
| Solution | Best for | Complexity | Testing |
|---|---|---|---|
| setState | Simple local state | Low | Hard |
| Provider | Medium apps, DI | Medium | Medium |
| Riverpod | Complex apps, compiled safety | Medium | Easy |
| BLoC | Enterprise, event-driven | High | Easy |
| GetX | Quick prototyping | Low | Hard |
For most apps, start with Provider or Riverpod. BLoC is preferred in large teams where event traceability matters. GetX is controversial due to its monolithic nature.
Common Mistakes
Putting all state in one provider: Split state into focused providers. A user provider, a settings provider, and a cart provider are easier to maintain than a single mega-provider.
Using context.read in build methods:
context.read<T>()should only be used in callbacks. Usecontext.watch<T>()orConsumer<T>in build methods to listen to changes.Not disposing providers that have streams: Providers that create stream subscriptions, timers, or controllers should implement
disposeto clean up resources.Creating providers inside build methods: Providers should be created once and stored. Creating them in
buildcauses state loss on every rebuild.Over-engineering with BLoC for simple apps: BLoC adds boilerplate. For a counter app,
setStateis sufficient. Choose the solution that matches the app's complexity.
Practice Questions
- How does Provider's
ChangeNotifierProvidernotify listeners of state changes? - What is the difference between
ref.watchandref.readin Riverpod? - How does BLoC separate events from state?
- When would you use Provider over Riverpod?
- Challenge: Build a todo list app with Riverpod. States: loading, loaded (with todo items), error. Actions: add todo, toggle completion, delete todo. Use FutureProvider for initial load and StateNotifierProvider for mutations.
Mini Project
Build a shopping cart app with Provider:
- ProductModel and CartModel as ChangeNotifiers
- Product list screen showing available products
- Cart screen showing added items and total
- Add to cart and remove from cart functionality
- Cart badge on app bar showing item count
- Persist cart using SharedPreferences
- Write unit tests for CartModel
FAQ
What is Next
Now that you understand state management, learn about navigation. Proceed to Flutter Navigation for routing, named routes, and deep linking. Then explore Flutter Forms for input handling and validation.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro