State Management Concepts
Compare Provider vs BLoC and learn architectural patterns for state management.
Local vs Global State
State management is one of the most important architectural decisions in any Flutter app. At its core, it's about answering one question: where does this data live, and who can access it? Getting this wrong leads to tangled code, hard-to-fix bugs, and poor performance. Getting it right makes your app predictable, testable, and easy to maintain.
The key insight is that not all state is equal. A text field's current value is local state — it only matters to that one widget. But the user's authentication status is global state — every screen might need it. Here's how to think about the different scopes:
| Scope | Example | Tool |
|---|---|---|
| Local State | Form input, toggle, animation | setState() |
| Screen State | Tab index, scroll position | ValueNotifier |
| Global State | Auth, cart, theme | Provider, BLoC, Riverpod |
setState(). Only reach for global state management when data must be shared across multiple widgets or screens.
Provider Pattern
Provider is the recommended state management solution for small-to-medium apps:
import 'package:flutter/material.dart';
class CounterProvider extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Triggers widget rebuild
}
void decrement() {
if (_count > 0) {
_count--;
notifyListeners();
}
}
void reset() {
_count = 0;
notifyListeners();
}
}
// Wrap MaterialApp with ChangeNotifierProvider
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterProvider(),
child: const MyApp(),
),
);
}
// Access state in widgets
class CounterDisplay extends StatelessWidget {
@override
Widget build(BuildContext context) {
var counter = Provider.of<CounterProvider>(context);
return Text('${counter.count}',
style: const TextStyle(fontSize: 64));
}
}
BLoC Pattern
Business Logic Component — separates UI from business logic using streams and events:
enum CounterEvent { increment, decrement, reset }
class CounterBloc {
int _count = 0;
final _eventController = StreamController<CounterEvent>.broadcast();
final _stateController = StreamController<int>.broadcast();
Stream<int> get stream => _stateController.stream;
Sink<CounterEvent> get eventSink => _eventController.sink;
CounterBloc() {
_eventController.stream.listen(_handleEvent);
}
void _handleEvent(CounterEvent event) {
switch (event) {
case CounterEvent.increment: _count++; break;
case CounterEvent.decrement: if (_count > 0) _count--; break;
case CounterEvent.reset: _count = 0; break;
}
_stateController.add(_count);
}
void dispose() {
_eventController.close();
_stateController.close();
}
}
When to Use Which Pattern
| Factor | Provider | BLoC |
|---|---|---|
| Learning Curve | Low — intuitive API | Higher — streams, events, states |
| Boilerplate | Minimal | More (events, states, bloc files) |
| Testability | Good with mock providers | Excellent — events in, states out |
| Scalability | Good for small-medium apps | Excellent for large teams |
| Debugging | Basic logging | BLoCObserver for full trace |
Provider vs BLoC
| Aspect | Provider | BLoC |
|---|---|---|
| Complexity | Low | Medium-High |
| Learning Curve | Easy | Steeper |
| Testability | Good | Excellent (streams) |
| Boilerplate | Minimal | More |
| Best For | Small-medium apps | Large, complex apps |
Real-World Example: Counter (Side by Side)
// Provider approach
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(children: [
Consumer<CounterProvider>(
builder: (ctx, counter, _) => Text('${counter.count}',
style: const TextStyle(fontSize: 48)),
),
ElevatedButton(
onPressed: () => Provider.of<CounterProvider>(context, listen: false).increment(),
child: const Text('Increment'),
),
]);
}
}
// BLoC approach
class CounterScreen extends StatefulWidget {
final CounterBloc bloc = CounterBloc();
@override
Widget build(BuildContext context) {
return Column(children: [
StreamBuilder<int>(
stream: bloc.stream,
builder: (ctx, snapshot) => Text('${snapshot.data ?? 0}',
style: const TextStyle(fontSize: 48)),
),
ElevatedButton(
onPressed: () => bloc.eventSink.add(CounterEvent.increment),
child: const Text('Increment'),
),
]);
}
}
Summary
setState()for local, ephemeral state- Provider: simple, reactive, best for most apps
- BLoC: stream-based, excellent testability, for complex apps
- Choose based on app complexity and team experience