Skip to main content

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

Two problems, often conflated:

  1. Where does the data live, so that it outlives a widget and can be shared by several widgets that are not parent and child?
  2. How does the UI learn it changed, without rebuilding more of the tree than necessary?

Split state by lifetime before choosing a tool:

KindExampleBelongs in
Ephemeral UI stateIs this dropdown open, current page of a PageViewState / setState
Screen stateForm values, the result of one requestA controller or notifier scoped to the route
App stateSession, cart, settings, cached entitiesA 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

SituationReasonable choice
Single widget, transientsetState
One value shared by a few widgetsValueNotifier
Small or medium app, team new to FlutterProvider
Medium to large app, heavy async, testability mattersRiverpod
Large app, complex flows, strict traceability, large teamBloc
Configuration constant for the whole appPlain 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 build contains 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 scoped ref.watch rather 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 in initState for local ones.
  • Holding BuildContext in 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.