Flutter State Management
Decision Guide
| Scope |
Solution |
| Local UI state (expand/collapse, tab index) |
setState |
| Single value shared across siblings |
ValueNotifier + ValueListenableBuilder |
| Domain model shared across features |
ChangeNotifier + ListenableBuilder |
| Complex app state, async, DI |
Riverpod (recommended) or BLoC |
| Legacy apps |
provider package |
setState — Local Only
class ToggleButton extends StatefulWidget {
const ToggleButton({super.key});
@override
State<ToggleButton> createState() => _ToggleButtonState();
}
class _ToggleButtonState extends State<ToggleButton> {
bool _on = false;
@override
Widget build(BuildContext context) => Switch(
value: _on,
onChanged: (v) => setState(() => _on = v),
);
}
ValueNotifier — Lightweight Observable
final counter = ValueNotifier<int>(0);
// Widget
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, _) => Text('$value'),
);
// Mutate
counter.value++;
// Dispose when done (in State.dispose or Provider)
counter.dispose();
ChangeNotifier — Multiple Fields
class CartNotifier extends ChangeNotifier {
final List<Item> _items = [];
List<Item> get items => List.unmodifiable(_items);
int get count => _items.length;
double get total => _items.fold(0, (s, i) => s + i.price);
void add(Item item) {
_items.add(item);
notifyListeners();
}
void remove(Item item) {
_items.remove(item);
notifyListeners();
}
}
// With provider package
ChangeNotifierProvider(create: (_) => CartNotifier()),
// Usage
ListenableBuilder(
listenable: cart,
builder: (context, _) => Text('${cart.count} items'),
);
Riverpod — Recommended
// Define
@riverpod
class Cart extends _$Cart {
@override
List<Item> build() => [];
void add(Item item) => state = [...state, item];
void remove(Item item) => state = state.where((i) => i.id != item.id).toList();
}
// Consume
class CartIcon extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartProvider).length;
return Badge(label: Text('$count'), child: const Icon(Icons.shopping_cart));
}
}
BLoC — Event-Driven
// Events
sealed class CartEvent {}
class AddItemEvent extends CartEvent { final Item item; AddItemEvent(this.item); }
class RemoveItemEvent extends CartEvent { final Item item; RemoveItemEvent(this.item); }
// State
class CartState { final List<Item> items; const CartState(this.items); }
// BLoC
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc() : super(const CartState([])) {
on<AddItemEvent>((event, emit) {
emit(CartState([...state.items, event.item]));
});
on<RemoveItemEvent>((event, emit) {
emit(CartState(state.items.where((i) => i.id != event.item.id).toList()));
});
}
}
// Widget
BlocBuilder<CartBloc, CartState>(
builder: (context, state) => Text('${state.items.length} items'),
);
context.read<CartBloc>().add(AddItemEvent(item));
Inherited State Anti-Patterns
// BAD: Lifting state too high unnecessarily
class AppState extends ChangeNotifier {
bool isMenuOpen = false; // local nav state doesn't belong at app level
}
// BAD: Calling setState after async without checking mounted
void _load() async {
final data = await fetch();
setState(() => _data = data); // may crash if widget is disposed
}
// GOOD:
void _load() async {
final data = await fetch();
if (!mounted) return;
setState(() => _data = data);
}
Reactive Streams
// StreamBuilder for live data
StreamBuilder<List<Message>>(
stream: chatService.messages,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
if (!snapshot.hasData) return const CircularProgressIndicator();
return MessageList(messages: snapshot.data!);
},
);
// FutureBuilder
FutureBuilder<User>(
future: userService.fetchCurrentUser(),
builder: (context, snapshot) => switch (snapshot.connectionState) {
ConnectionState.waiting => const CircularProgressIndicator(),
ConnectionState.done when snapshot.hasData => UserCard(user: snapshot.data!),
_ => const Text('Error'),
},
);