State management
From setState to Bloc, what each approach actually solves, and how to pick one
without starting a religious argument.
Contents
- What state management is for
- setState
- ValueNotifier
- InheritedWidget
- Provider
- Riverpod
- Bloc and Cubit
- Choosing
- Common mistakes
What state management is for
Two problems, often conflated:
- Where does the data live, so that it outlives a widget and can be shared by several widgets that are not parent and child?
- How does the UI learn it changed, without rebuilding more of the tree than necessary?
Split state by lifetime before choosing a tool:
| Kind | Example | Belongs in |
|---|---|---|
| Ephemeral UI state | Is this dropdown open, current page of a PageView | State / setState |
| Screen state | Form values, the result of one request | A controller or notifier scoped to the route |
| App state | Session, cart, settings, cached entities | A shared store, injected at the root |
Most apps need a tool only for the third row. Reaching for a global store to track whether a checkbox is ticked is how simple screens become unreadable.
setState
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) => Column(
children: [
Text('$_count'),
FilledButton(
onPressed: () => setState(() => _count++),
child: const Text('Add'),
),
],
);
}
setState marks this element dirty; the framework rebuilds it in the next frame
(see Rendering pipeline).
Limits: state dies with the widget, cannot be read by a sibling, and the rebuild
covers the whole build method. It stops being adequate the moment two distant
widgets need the same value.
ValueNotifier
The lightest way to share a single value and rebuild only the listeners.
final selectedIndex = ValueNotifier<int>(0);
ValueListenableBuilder<int>(
valueListenable: selectedIndex,
builder: (context, index, child) => NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => selectedIndex.value = i,
destinations: const [...],
),
)
No package required, and the rebuild is scoped to the builder. Dispose notifiers
you own. For anything with more than one field, a ChangeNotifier is the next
step up.
InheritedWidget
The mechanism every other solution is built on: it makes a value available to the subtree and rebuilds dependents when it changes.
class AppConfigScope extends InheritedWidget {
const AppConfigScope({
super.key,
required this.config,
required super.child,
});
final AppConfig config;
static AppConfig of(BuildContext context) {
final scope =
context.dependOnInheritedWidgetOfExactType<AppConfigScope>();
assert(scope != null, 'AppConfigScope missing from the tree');
return scope!.config;
}
@override
bool updateShouldNotify(AppConfigScope oldWidget) =>
config != oldWidget.config;
}
dependOnInheritedWidgetOfExactType registers the calling element as a
dependent — that is the subscription. Theme, MediaQuery, and Navigator all
work this way.
Writing these by hand is fine for a config object that rarely changes; for mutable app state, use one of the packages below rather than reimplementing them.
Provider
A thin, well-understood wrapper over InheritedWidget.
flutter pub add provider
class CartModel extends ChangeNotifier {
final _items = <Product>[];
List<Product> get items => List.unmodifiable(_items);
double get total => _items.fold(0, (sum, p) => sum + p.price);
void add(Product product) {
_items.add(product);
notifyListeners();
}
}
MultiProvider(
providers: [
Provider<ProductRepository>(create: (_) => ProductRepository(dio)),
ChangeNotifierProvider(create: (_) => CartModel()),
],
child: const MyApp(),
)
Reading it:
context.watch<CartModel>() // subscribe, rebuild on change
context.read<CartModel>() // one-off read, no subscription
context.select<CartModel, double>((c) => c.total) // rebuild only if total changes
Consumer<CartModel>(
builder: (context, cart, child) => Text('${cart.items.length}'),
)
read in callbacks, watch in build, select when you depend on one field of
a large model. Calling watch outside build throws, and calling read in
build gives you a value that never updates — those two mistakes account for
most Provider confusion.
Riverpod
Riverpod keeps the ideas but removes the dependency on the widget tree: providers are top-level objects, so they are testable without pumping widgets and cannot throw "provider not found" at runtime.
flutter pub add flutter_riverpod
final productRepositoryProvider = Provider<ProductRepository>(
(ref) => ProductRepository(ref.watch(dioProvider)),
);
final productsProvider = FutureProvider<List<Product>>((ref) async {
return ref.watch(productRepositoryProvider).fetchProducts();
});
class CartNotifier extends Notifier<List<Product>> {
@override
List<Product> build() => const [];
void add(Product product) => state = [...state, product];
}
final cartProvider = NotifierProvider<CartNotifier, List<Product>>(
CartNotifier.new,
);
class ProductList extends ConsumerWidget {
const ProductList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = ref.watch(productsProvider);
return products.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => ErrorView(error: error),
data: (items) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) => ProductTile(product: items[i]),
),
);
}
}
Wrap the app in ProviderScope. The AsyncValue returned by async providers
models loading, data, and error as one value, which removes the usual trio of
isLoading/data/error fields. ref.watch subscribes, ref.read does not,
ref.listen runs side effects such as showing a snackbar.
Note that the Riverpod API has changed meaningfully across major versions
(StateNotifier to Notifier, code generation with @riverpod). Follow the
documentation for the version you pin rather than older tutorials.
Bloc and Cubit
Bloc makes state transitions explicit and traceable: events go in, states come out, and everything is observable.
flutter pub add flutter_bloc
Cubit is the light form — methods instead of events:
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
Bloc adds an event layer, which pays off when transitions are complex or need an audit trail:
sealed class SearchEvent {}
final class QueryChanged extends SearchEvent {
QueryChanged(this.query);
final String query;
}
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc(this._repo) : super(const SearchState.initial()) {
on<QueryChanged>(
_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 300)),
);
}
final ProductRepository _repo;
Future<void> _onQueryChanged(
QueryChanged event,
Emitter<SearchState> emit,
) async {
emit(const SearchState.loading());
final result = await _repo.search(event.query);
emit(switch (result) {
Ok(value: final items) => SearchState.results(items),
Err(failure: final f) => SearchState.failure(f),
});
}
}
BlocProvider(
create: (_) => SearchBloc(context.read<ProductRepository>()),
child: BlocBuilder<SearchBloc, SearchState>(
builder: (context, state) => switch (state) {
SearchLoading() => const CircularProgressIndicator(),
SearchResults(items: final items) => ResultList(items: items),
SearchFailure(failure: final f) => ErrorView(failure: f),
_ => const SizedBox.shrink(),
},
),
)
BlocBuilder rebuilds, BlocListener runs side effects (navigation, snackbars),
BlocConsumer does both. Event transformers give you debounce, throttle, and
drop semantics for free — that is a real advantage for search and form
validation.
Choosing
| Situation | Reasonable choice |
|---|---|
| Single widget, transient | setState |
| One value shared by a few widgets | ValueNotifier |
| Small or medium app, team new to Flutter | Provider |
| Medium to large app, heavy async, testability matters | Riverpod |
| Large app, complex flows, strict traceability, large team | Bloc |
| Configuration constant for the whole app | Plain InheritedWidget |
Criteria that actually matter more than the tool: can you test business logic without a widget tree; can you tell what rebuilt and why; and does a new developer find the state for a screen in one obvious place. Any of the above satisfies all three when used consistently — mixing three of them in one codebase satisfies none.
Common mistakes
- Business logic in widgets. If
buildcontains a network call or a pricing rule, it cannot be tested and will be duplicated. Move it behind a notifier, bloc, or repository. - Rebuilding too much. Wrap the smallest subtree; use
select,Consumer, or a scopedref.watchrather than watching a whole model at the top of a page. - Mutating state in place. Emitting a list you mutated means the old and new state are the same object, equality passes, and the UI does not update. Emit a new list.
- Creating notifiers inside
build. They get recreated on every rebuild. Create in a provider, or ininitStatefor local ones. - Holding
BuildContextin a store. It ties app state to a widget lifetime and leaks. Pass values out; let the UI decide what to do with them. - Not disposing. Notifiers, controllers, and subscriptions owned by a widget must be disposed — see Lifecycle.
Next: Lifecycle.