State management all 5 written
From setState to Bloc and Riverpod, and the question underneath all of them: who owns this state?
19 Who owns this state?
Before Bloc, before Riverpod, before any argument about which is better, there is one question: which widget owns this value? Get it right and every library works. Get it wrong and no library saves you — you will just have the same bug in a more elaborate wrapper.
Two ways to be wrong
- Too low. The state lives inside a widget that something above is free to destroy. It disappears, apparently at random, and the bug report says "it forgot my selection".
- Too high. The state lives above everything that reads it, so a change to it rebuilds screens that have nothing to do with it. Nothing breaks; the app is just slow, and the cause is invisible.
Too low, demonstrated
A list of forty rows, each keeping its own tick. ListView.builder destroys
the Elements of rows that scroll out of view, and the State goes with them.
Scroll down, scroll back, and the tick is gone.
The test to apply is a question about lifetime, not about tidiness: can the widget
holding this value be destroyed while the value still matters? A row in a long list
can. A row in a dialog cannot. A screen in a Navigator stack can, when you
push over it and pop back. If the answer is yes, the value does not belong there.
Not everything should move up. A text field's cursor position, an expansion tile's open flag, a button's pressed highlight — these genuinely belong to the widget, and hoisting them buys nothing but noise. The distinction is whether anything else needs the value, and whether it must outlive the widget. If neither, leave it alone.
Too high, demonstrated
The overcorrection is to put everything at the top of the app. Nothing is lost, so it feels safe. What it costs is that every change rebuilds from the root — including whole panels that never read the value.
Both versions are correct. Both show total 250p. The difference is that one
rebuilt MaterialApp, its Navigator, its theme lookup and a
settings panel to change a number that two widgets read. Multiply that by a real screen and
by every keystroke in a search field, and it is the difference between a smooth app and a
mystery.
The rule
Lift state to the lowest common ancestor of everything that reads it. Not higher, because everything above is rebuild you are paying for and cannot see. Not lower, because below is a lifetime you do not control. It is a mechanical rule: list the widgets that read the value, find where their paths meet, put it there.
// Where does this value belong? Take the path from the root to each widget that
// reads it, and walk down together until the paths part company.
List<String> lowestCommonAncestor(List<List<String>> paths) {
if (paths.isEmpty) return const [];
final shared = <String>[];
for (var i = 0; i < paths.first.length; i++) {
final step = paths.first[i];
if (paths.any((p) => i >= p.length || p[i] != step)) break;
shared.add(step);
}
return shared;
}
void main() {
// A stepper and a total, both inside the basket.
const stepper = ['App', 'Home', 'Basket', 'Stepper'];
const total = ['App', 'Home', 'Basket', 'Summary', 'Total'];
// The signed-in user's name, read from the basket and from a drawer far away.
const drawer = ['App', 'Drawer', 'Avatar'];
print('quantity belongs in ${lowestCommonAncestor([stepper, total]).last}');
print('the user belongs in ${lowestCommonAncestor([total, drawer]).last}');
test('the quantity belongs in the basket',
lowestCommonAncestor([stepper, total]), ['App', 'Home', 'Basket']);
test('a value two screens apart really does belong at the top',
lowestCommonAncestor([total, drawer]), ['App']);
test('one reader means the widget itself owns it',
lowestCommonAncestor([stepper]), stepper);
test('no readers means the value should not exist',
lowestCommonAncestor([]), <String>[]);
}
What it printed, when the build ran it
quantity belongs in Basket the user belongs in App ok the quantity belongs in the basket ok a value two screens apart really does belong at the top ok one reader means the widget itself owns it ok no readers means the value should not exist
The third assertion is the useful one. When only one widget reads a value, the lowest
common ancestor is that widget, and setState is the correct answer.
Most state in most apps has exactly one reader, which is why a well-built Flutter app has
far less in its state management layer than people expect.
The fourth assertion is the one to say out loud in an interview. If nothing reads a
value, it is not state — it is a variable you forgot to delete, or a derived value you
should be computing. A surprising amount of "state management complexity" is storing
things that could be calculated: a total next to the items, an
isEmpty next to the list, a filteredItems next to the filter.
Each one is a chance for two sources of truth to disagree.
Where the libraries come in. Apply the rule honestly on a real app
and some values land at the root — the signed-in user, the theme, the cart, the
connectivity state. Passing those down through twelve constructors is miserable, and
every widget in between has to know about a value it does not use. That is the problem
InheritedWidget solves, and it is the next lesson. Note the order: the rule
first, the tool second.
20 InheritedWidget, which is what the libraries use
Lesson 19 ended with a real problem: some values genuinely belong
at the top, and passing them down through twelve constructors is miserable.
InheritedWidget is the framework's answer, and it is the only answer — Provider,
Riverpod and flutter_bloc are all built on it. It is about sixty lines to write
one properly.
What it actually gives you
- Lookup by type, in O(1). Every Element keeps a map of the inherited
widgets above it, so
Theme.of(context)is a hash lookup, not a walk up the tree. - A dependency registration. Asking for the value also signs you up to be rebuilt when it changes. That registration is the interesting half.
- A veto.
updateShouldNotifydecides whether a new value is worth waking anybody for.
The two lookups are not the same, and the difference is exactly the
watch versus read distinction every library then re-invents:
dependOnInheritedWidgetOfExactType<T>()— read the value and register. You will be rebuilt on change. This is whatof(context)normally calls.getInheritedWidgetOfExactType<T>()— read the value and do not register. For a one-off read in a callback, where a rebuild would be pointless.
One, built from scratch, with the builds counted
Three numbers to read out of that. The dependent rebuilt when the factor changed even
though it is const and its parent handed the framework the identical instance
— a dependency is a second, separate reason for an Element to be marked dirty. The
bystander did not, despite sitting next to it under the same rebuilt parent. And the churn
tap rebuilt the whole Host and produced a brand new Scale widget,
and still nothing woke, because updateShouldNotify compared the factors and
said no.
updateShouldNotify is where an InheritedWidget goes wrong in
practice. Returning true unconditionally works and quietly rebuilds every
dependent on every parent rebuild. Comparing a mutable object with == —
a List, or a class without == — compares identity, so an
in-place mutation looks like no change at all and nothing rebuilds. Immutable state with
real value equality is not a style preference here; it is what makes the comparison
mean something.
The bookkeeping, in plain Dart
The framework's version of this is a set of dependent Elements per inherited Element, plus one call to the veto. Written out, there is not much to it — which is the point of writing it out.
// A stand-in for the framework's bookkeeping: who asked, and whether to tell them.
class Scope<T> {
Scope(this.value);
T value;
final _dependents = <String>{};
/// dependOnInheritedWidgetOfExactType: read the value AND register.
T watch(String who) {
_dependents.add(who);
return value;
}
/// getInheritedWidgetOfExactType: read the value, register nothing.
T read() => value;
/// Returns everyone who must be rebuilt.
Set<String> update(T next, bool Function(T oldValue, T newValue) shouldNotify) {
final worthTelling = shouldNotify(value, next);
value = next;
return worthTelling ? {..._dependents} : <String>{};
}
}
bool changed(int a, int b) => a != b;
void main() {
final scope = Scope<int>(1);
scope.watch('Header');
scope.watch('Badge');
print('a bystander reads ${scope.read()} and registers nothing');
final woken = scope.update(2, changed);
final quiet = scope.update(2, changed);
print('changed 1 to 2 -> woke $woken');
print('changed 2 to 2 -> woke $quiet');
test('everyone who asked is rebuilt', woken, {'Header', 'Badge'});
test('nobody who did not ask is rebuilt', woken.contains('Bystander'), false);
test('an unchanged value wakes nobody', quiet, <String>{});
test('and the value is still there to be read', scope.read(), 2);
}
What it printed, when the build ran it
a bystander reads 1 and registers nothing
changed 1 to 2 -> woke {Header, Badge}
changed 2 to 2 -> woke {}
ok everyone who asked is rebuilt
ok nobody who did not ask is rebuilt
ok an unchanged value wakes nobody
ok and the value is still there to be read
What it does not do
An InheritedWidget is immutable, like every widget. It cannot hold changing
state; it can only carry a value down. Something above it must own the value and rebuild
it, which is why the test above needed a StatefulWidget wrapper. That pairing
— a StatefulWidget owning the value, an InheritedWidget
publishing it — is the entire shape of every library in this part.
- Provider is that pairing plus a
ChangeNotifier, so you do not write theStatefulWidget. - flutter_bloc is that pairing plus a stream, with
BlocProvideras the inherited part andBlocBuilderas the subscriber. - Riverpod puts one
InheritedWidgetat the root holding a container, and does the rest outside the widget tree entirely. Lesson 22.
Two traps with of(context). Calling it in
initState is not safe — the dependency machinery is not ready, and the
framework will tell you so; use didChangeDependencies. And calling it in
dispose can fail, because what you depended on may already be gone. Capture
what you need in didChangeDependencies and use the captured value on the way
out.
Say this plainly in an interview and it lands: "Provider and Riverpod are not
alternatives to InheritedWidget, they are packaging for it. The rebuild
scoping people credit to the library is the framework's dependency mechanism, and
Theme.of(context) has always used it." Then mention
updateShouldNotify, because that is the part a person who has only read the
docs will not bring up.
21 Bloc and Cubit, built from scratch first
A Cubit is a value, a stream of changes to it, and methods that change it. That is the whole idea, and it is short enough to write out — so this lesson writes it out, tests it, and wires it to a widget before showing you the package.
Build it first
Nothing below needs a dependency. Every line ran during the build.
import 'dart:async';
/// A Cubit, in the lines it actually is: a current value, a broadcast stream of
/// changes, and one guarded way to move from one value to the next.
class Cubit<S> {
Cubit(this._state);
S _state;
final _out = StreamController<S>.broadcast();
S get state => _state;
Stream<S> get stream => _out.stream;
/// The guard matters. Bloc really does drop a state equal to the current one,
/// which is why Bloc states are expected to have value equality.
void emit(S next) {
if (next == _state) return;
_state = next;
_out.add(next);
}
Future<void> close() => _out.close();
}
/// A feature is now a class with a starting value and some verbs.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
Future<void> main() async {
final counter = CounterCubit();
final seen = <int>[];
final sub = counter.stream.listen(seen.add);
counter.increment();
counter.increment();
counter.decrement();
counter.emit(1); // equal to the current state, so it is dropped
counter.reset();
await Future<void>.delayed(Duration.zero); // let the stream deliver
print('state: ${counter.state}');
print('seen: $seen');
test('the state is the last value emitted', counter.state, 0);
test('listeners saw every change, in order', seen, [1, 2, 1, 0]);
test('an equal state is dropped rather than re-emitted', seen.length, 4);
test('the stream is broadcast, so many widgets can listen', counter.stream.isBroadcast, true);
await sub.cancel();
await counter.close();
}
What it printed, when the build ran it
state: 0 seen: [1, 2, 1, 0] ok the state is the last value emitted ok listeners saw every change, in order ok an equal state is dropped rather than re-emitted ok the stream is broadcast, so many widgets can listen
The emit guard is the part worth noticing. It compares with
==, so if your state is a plain class without an equality override, every
emit looks like a change and the guard never fires. That is the entire reason the Bloc
documentation pushes Equatable or freezed — not style, but a
comparison that has to mean something.
Wire it to a widget
BlocBuilder is a StreamBuilder with a nicer name and a
buildWhen filter. Here is the hand-built Cubit driving a real screen, in a
real widget test, with the builds counted so the rebuild scope is visible.
One and three. The screen was built once and never again; the label was built for the first frame and then once per state. That is the same narrowing as lesson 17, arrived at from a different direction — the subscriber sits low in the tree, so the rebuild does too.
Events, and why Bloc bothers with them
A Cubit exposes methods. A Bloc exposes a single add(event) and one
function that maps an event to the next state. The extra step is real work, so it should
buy something. Here is the version with events, built and tested the same way.
// Events as data, which is the entire difference.
sealed class CartEvent {}
class ItemAdded extends CartEvent {
ItemAdded(this.sku);
final String sku;
}
class ItemRemoved extends CartEvent {
ItemRemoved(this.sku);
final String sku;
}
class CartCleared extends CartEvent {}
/// A record, so the state has value equality for free - which is what the
/// "drop an equal state" rule needs to work at all.
typedef Cart = ({int lines, String last});
Cart reduce(Cart cart, CartEvent event) => switch (event) {
ItemAdded(:final sku) => (lines: cart.lines + 1, last: sku),
ItemRemoved(:final sku) => (lines: cart.lines > 0 ? cart.lines - 1 : 0, last: sku),
CartCleared() => (lines: 0, last: ''),
};
/// A Bloc: a Cubit where the outside world sends data instead of calling methods,
/// and one function decides what each piece of data does.
class Bloc<E, S> {
Bloc(this._state, this._reduce);
S _state;
final S Function(S state, E event) _reduce;
final received = <E>[];
final emitted = <S>[];
S get state => _state;
void add(E event) {
received.add(event);
final next = _reduce(_state, event);
if (next == _state) return;
_state = next;
emitted.add(next);
}
}
void main() {
final cart = Bloc<CartEvent, Cart>((lines: 0, last: ''), reduce);
cart.add(ItemAdded('tea'));
cart.add(ItemAdded('jam'));
cart.add(ItemRemoved('jam'));
cart.add(CartCleared());
cart.add(CartCleared()); // already empty, so no new state comes out
print('state: ${cart.state}');
print('received: ${cart.received.length}, emitted: ${cart.emitted.length}');
test('the state is the fold of every event', cart.state, (lines: 0, last: ''));
test('every event was recorded, including the one that changed nothing',
cart.received.length, 5);
test('an event that changes nothing emits no state', cart.emitted.length, 4);
test('the log is a replayable history, which methods do not give you',
cart.received.map((e) => e.runtimeType.toString()).toList(),
['ItemAdded', 'ItemAdded', 'ItemRemoved', 'CartCleared', 'CartCleared']);
}
What it printed, when the build ran it
state: (last: , lines: 0) received: 5, emitted: 4 ok the state is the fold of every event ok every event was recorded, including the one that changed nothing ok an event that changes nothing emits no state ok the log is a replayable history, which methods do not give you
Five received, four emitted. That gap is the argument for events, and it is the honest version of it:
- Every intention is a value. You can log it, store it, send it to analytics, or replay a session from it. A method call leaves no trace.
- One place decides all transitions. With methods, the rules are spread across every method. With a reducer, the whole state machine is one exhaustive switch, and a sealed event type means the compiler checks you handled all of it.
- Events can be transformed before they land. Debounce a search event,
drop duplicate submits, process sequentially. That is
flutter_bloc'sEventTransformer, and it is genuinely hard to retrofit onto methods.
And the honest cost. A counter written as a Bloc is an event class, a state class, a registration and a switch arm to add one. That is why Cubit exists and why the Bloc authors recommend it as the default. The decision is per feature, not per app: events where the history or the transformation matters, methods everywhere else. Saying "we used Cubit for most things and Bloc for the search and checkout flows" is a much stronger answer than either purist position.
The package, for recognition
What you have built maps one-to-one onto the real API. The names are different; the shapes are not.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// Cubit and emit come from the package. Everything else is the same class you
// wrote above.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
// BlocProvider is the InheritedWidget from lesson 20. It also owns the Cubit's
// lifetime, and closes it when the provider leaves the tree.
return BlocProvider(
create: (_) => CounterCubit(),
child: Builder(
builder: (context) => Column(
children: [
// BlocBuilder is a StreamBuilder. buildWhen is the filter our version
// did not have: rebuild only when the part you care about changed.
BlocBuilder<CounterCubit, int>(
buildWhen: (previous, current) => current.isEven,
builder: (context, count) => Text('count $count'),
),
// read() is getInheritedWidgetOfExactType: fetch it, do not subscribe.
TextButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: const Text('add'),
),
],
),
),
);
}
}
// A Bloc, where events are registered rather than switched on.
sealed class CartEvent {}
class ItemAdded extends CartEvent {}
class CartBloc extends Bloc<CartEvent, int> {
CartBloc() : super(0) {
on<ItemAdded>((event, emit) => emit(state + 1));
}
}
flutter_bloc is not installed, and this course adds no third-party package to a panel it claims to have verified. Everything this listing does was built and tested from scratch above; this is here so you recognise the names.
Three things in that listing are the ones to know. BlocProvider owns the
Cubit's lifetime, so if you construct the Cubit yourself and pass it in, you must use
BlocProvider.value and close it yourself — getting that backwards is the
commonest flutter_bloc bug. context.read does not subscribe and
context.watch does, which is lesson 20's two lookups. And
BlocListener exists for the things that are not UI: a snackbar, a navigation
push, a haptic. Doing those in builder means doing them again on every
rebuild.
22 Riverpod, and how it differs
Riverpod's central move is to take the state out of the widget
tree. Providers live in a container; the widget tree gets one
InheritedWidget at the root pointing at it. Everything else — caching,
dependencies between providers, overriding one for a test — happens in the container,
where there is no BuildContext to get wrong.
Three consequences, which are the whole pitch
- A provider is a graph node. One provider can read another. Riverpod
remembers the edge, so invalidating a value invalidates everything computed from it. No
library built on
of(context)can do this, because the tree has no idea which values were derived from which. - The provider is the key. It is a top-level
finalthat carries its own type. There is no string to mistype and no type to get wrong — a mismatch is a compile error, not aProviderNotFoundExceptionat run time. - No
BuildContextneeded. Reading a provider outside the tree is normal, not a workaround. That is what makes the tests below possible without a widget at all.
The graph, small enough to test
A provider container is a cache plus a record of which provider read which. That is enough to reproduce the two behaviours that matter: a value is built once and reused, and invalidating a leaf drops everything downstream of it.
// A provider: a name, and a function that builds its value from other providers.
class Provider<T> {
Provider(this.name, this.create);
final String name;
final T Function(ProviderContainer ref) create;
}
/// The container. Riverpod's real one is more careful about lifetimes, but the two
/// parts that give it its character are here: a cache, and the edges between nodes.
class ProviderContainer {
ProviderContainer({Map<Provider<Object?>, Object?> overrides = const {}})
: _cache = {...overrides};
final Map<Provider<Object?>, Object?> _cache;
final _reading = <Provider<Object?>>[];
final _readers = <String, Set<Provider<Object?>>>{};
/// Every name that was actually computed, in the order it was reached.
final built = <String>[];
T read<T>(Provider<T> provider) {
if (_reading.isNotEmpty) {
(_readers[provider.name] ??= <Provider<Object?>>{}).add(_reading.last);
}
if (_cache.containsKey(provider)) return _cache[provider] as T;
built.add(provider.name);
_reading.add(provider);
final value = provider.create(this);
_reading.removeLast();
_cache[provider] = value;
return value;
}
/// Drop this value and everything that was computed from it.
Set<String> invalidate(Provider<Object?> provider) {
final dropped = <String>{};
void drop(Provider<Object?> node) {
if (!dropped.add(node.name)) return;
_cache.remove(node);
for (final reader in _readers[node.name] ?? const <Provider<Object?>>{}) {
drop(reader);
}
}
drop(provider);
return dropped;
}
}
// The graph. Note there is no context, no tree, and no order of declaration to get
// right - config is declared first only because it reads better.
final config = Provider<String>('config', (ref) => 'https://api.example.com');
final client = Provider<String>('client', (ref) => 'client(${ref.read(config)})');
final repo = Provider<String>('repo', (ref) => 'repo(${ref.read(client)})');
void main() {
final container = ProviderContainer();
final value = container.read(repo);
final order = [...container.built];
print('value: $value');
print('built: $order');
final before = container.built.length;
container.read(repo);
final after = container.built.length;
final dropped = container.invalidate(config);
print('invalidating config dropped: $dropped');
// A whole graph with one leaf swapped, which is how a Riverpod test is set up.
final fake = ProviderContainer(overrides: {config: 'https://fake.local'});
test('reading the top of the graph walks down it', order, ['repo', 'client', 'config']);
test('the value is composed from the whole chain', value,
'repo(client(https://api.example.com))');
test('a second read builds nothing', after, before);
test('invalidating a leaf drops everything computed from it', dropped,
{'config', 'client', 'repo'});
test('an override at the leaf changes everything above it', fake.read(repo),
'repo(client(https://fake.local))');
}
What it printed, when the build ran it
value: repo(client(https://api.example.com))
built: [repo, client, config]
invalidating config dropped: {config, client, repo}
ok reading the top of the graph walks down it
ok the value is composed from the whole chain
ok a second read builds nothing
ok invalidating a leaf drops everything computed from it
ok an override at the leaf changes everything above it
The fourth and fifth assertions are the ones Provider and Bloc cannot match. Changing one leaf invalidated two derived values without either of them knowing about the change, and setting up a whole test double took one line and no widget tree. That is the argument for the graph, and it is a real argument.
The compile-time safety claim, tested
"Compile-safe" gets said about Riverpod a lot without being pinned down. What it means
is narrow and worth stating precisely: because a provider is a typed object rather than a
string or a Type, a mismatch is caught by the analyser. Compare a service
locator keyed by String, where every lookup is a run-time gamble you have to
remember to check.
// A provider carries its own type, so the type is checked where it is used.
class Provider<T> {
const Provider(this.name);
final String name;
}
const port = Provider<int>('port');
/// A screen that needs the base URL, so it asks for a Provider<String>.
String baseUrl(Provider<String> provider) => provider.name;
void main() {
print(baseUrl(port)); // a Provider<int> is not a Provider<String>
}
What the analyser said
line 13: The argument type 'Provider<int>' can't be assigned to the parameter type 'Provider<String>'. [argument_type_not_assignable]
That is the whole of it. It does not mean Riverpod prevents you forgetting a provider — forget to override one in a test and you get the real implementation, quietly. And in Riverpod 1 it did not prevent reading a provider that was never in scope either; it moved the failure from "not found" to "not the type you said". Useful, and smaller than the marketing.
The real API
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ApiClient {
ApiClient(this.baseUrl);
final String baseUrl;
Future<String> fetchName() async => 'Ada';
}
// A provider is a top-level final. It is the key and the definition at once, which
// is where the type safety comes from: there is nothing to mistype.
final configProvider = Provider<String>((ref) => 'https://api.example.com');
// ref.watch inside a provider is the graph edge. Change configProvider and this
// rebuilds, and so does anything that watched this.
final clientProvider = Provider<ApiClient>((ref) => ApiClient(ref.watch(configProvider)));
// Async is a provider kind, not something you model by hand. autoDispose throws the
// value away when the last listener goes, which is the cache policy most screens want.
final nameProvider = FutureProvider.autoDispose<String>((ref) async {
return ref.watch(clientProvider).fetchName();
});
// Mutable state is a Notifier, which is a Cubit with different names.
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
final counterProvider = NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// watch subscribes and rebuilds this widget. read does not.
final count = ref.watch(counterProvider);
final name = ref.watch(nameProvider);
return Column(
children: [
Text('count $count'),
// AsyncValue makes you answer for all three states, or it will not compile.
name.when(
loading: () => const CircularProgressIndicator(),
error: (error, stack) => Text('failed: $error'),
data: (value) => Text(value),
),
TextButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Text('add'),
),
],
);
}
}
// A test needs no widget tree at all: build a container, swap a leaf, read the top.
//
// final container = ProviderContainer(
// overrides: [configProvider.overrideWithValue('https://fake.local')],
// );
// addTearDown(container.dispose);
// expect(container.read(clientProvider).baseUrl, 'https://fake.local');
flutter_riverpod is not installed, and this course adds no third-party package to a panel it claims to have verified. The container, the graph edges and the override behaviour were all built and tested from scratch above; this listing is here so you recognise the names.
Against Bloc, honestly
- Boilerplate. Riverpod wins clearly for reading and deriving. A
derived value is one
ref.watch; in Bloc it is another Bloc, or a stream subscription you wire and close yourself. - Async. Riverpod wins.
FutureProviderplusAsyncValuegives you loading, data and error as an exhaustive type, with caching and cancellation handled. Bloc makes you write those three states yourself, every time, for every feature. - Traceability. Bloc wins. Every change arrives as an event object you can log, replay and assert on, and a reducer means the whole state machine is in one exhaustive switch. Riverpod's mutations are method calls, so a state change has no artefact.
- Testability. Different rather than better.
bloc_testasserts on a sequence of emitted states, which is excellent for a state machine. Riverpod's container plus overrides is excellent for a dependency graph. Neither needs a widget. - Discipline on a large team. Bloc wins, and this is the reason it stays in enterprise codebases. It has one way to do things. Riverpod is more expressive, and expressiveness is what a twelve-person team disagrees about.
- Lifetimes. Riverpod's
autoDisposeandfamilyare a genuinely better answer than "put the provider above the route and hope", but they are also the part people get wrong. A missingautoDisposeis a cache that never clears.
The cost nobody mentions. Riverpod's dependency graph is invisible in the code. Nothing in a file tells you which providers watch it, so a change to a leaf can have effects several files away, and the "what rebuilds when this changes" question stops being answerable by reading. Bloc's explicitness is verbose, and verbosity is also legibility. Which of those two costs your team can afford is a real question, and answering it with a preference rather than a reason is what marks out a mid-level answer.
If you are asked to compare them, resist listing features. The useful comparison is one sentence: Bloc models a feature as a state machine you can replay, Riverpod models an app as a dependency graph you can cache and override. Then say which of those two your last app actually needed, and why. That is the answer to lesson 23.
23 Choosing one, and defending the choice
"Which state management do you prefer?" is almost never a question about libraries. It is a question about whether you have ever had to live with the consequences of the choice — and the answer that fails is the one that names a package and stops.
What is actually being asked
- Do you know what problem it solves? Not "manages state" — which specific pain. Lesson 19's rule produces values that belong at the root; the library exists to deliver those without threading them through twelve constructors.
- Do you know what it costs? Every one of them costs something. Someone who says their favourite has no downside has not maintained an app with it for two years.
- Can you change your mind for a reason? The best answers include a case where the interviewer's preferred option would be the right one. That is not a concession, it is the evidence that the preference is reasoned.
- Would you be tolerable to work with? A large part of a senior role is making a call that others have to live in. "I would use what the team already uses unless there is a reason to move" is a better answer than a strong preference, most of the time.
The number the library does not decide
Start here, because it defuses most of the argument. The rebuild scope people attribute
to Bloc or Riverpod is a consequence of where the value is read, and
setState in the right widget beats any library in the wrong one.
// One screen, one value change, three arrangements. The numbers are a stand-in for
// a real screen - 120 widgets under the owner, 30 of them const, 3 that display the
// value - and what is being asserted is the ratio, not the count.
int setStateAtTheTop(int subtree, int frozen) => subtree - frozen;
int setStateInTheRightWidget(int localSubtree, int frozen) => localSubtree - frozen;
int scopedBuilder(int readers) => readers;
void main() {
print('setState at the top -> ${setStateAtTheTop(120, 30)} widgets');
print('setState in the right widget -> ${setStateInTheRightWidget(9, 2)} widgets');
print('a scoped builder -> ${scopedBuilder(3)} widgets');
test('setState at the top rebuilds most of the screen', setStateAtTheTop(120, 30), 90);
test('the same call, moved down, rebuilds a corner of it',
setStateInTheRightWidget(9, 2), 7);
test('a scoped read rebuilds only the widgets that read', scopedBuilder(3), 3);
test('so moving the state was worth a factor of twelve, with no library at all',
setStateAtTheTop(120, 30) ~/ setStateInTheRightWidget(9, 2), 12);
}
What it printed, when the build ran it
setState at the top -> 90 widgets setState in the right widget -> 7 widgets a scoped builder -> 3 widgets ok setState at the top rebuilds most of the screen ok the same call, moved down, rebuilds a corner of it ok a scoped read rebuilds only the widgets that read ok so moving the state was worth a factor of twelve, with no library at all
The last line is the one to say out loud. A library that scopes reads gets you from seven to three; putting the state in the right widget got you from ninety to seven. If a candidate reaches for a package before answering the ownership question, they have skipped the larger factor.
The three screens you owe the user
Async is where the libraries genuinely differ, and the difference is how much they let you get away with. Every asynchronous value has a loading state, a success state and a failure state. Model it with a couple of flags and a nullable value and you have not removed those states, you have added five more that mean nothing.
/// The shape most teams reach for first.
class Flags {
const Flags({this.loading = false, this.data, this.error});
final bool loading;
final String? data;
final String? error;
/// Is this a combination you have actually designed a screen for?
bool get isDrawable {
if (loading) return data == null && error == null;
return (data == null) != (error == null);
}
}
/// The shape all of these libraries push you towards instead. AsyncValue in
/// Riverpod, a sealed state class in Bloc, and the same three arms either way.
sealed class Async {
const Async();
}
class Loading extends Async {
const Loading();
}
class Ready extends Async {
const Ready(this.value);
final String value;
}
class Failed extends Async {
const Failed(this.error);
final String error;
}
String screenFor(Async state) => switch (state) {
Loading() => 'spinner',
Ready(:final value) => 'content: $value',
Failed(:final error) => 'retry: $error',
};
void main() {
// Every combination the three fields allow.
final combinations = <Flags>[];
for (final loading in [true, false]) {
for (final data in [null, 'ada']) {
for (final error in [null, 'offline']) {
combinations.add(Flags(loading: loading, data: data, error: error));
}
}
}
final drawable = combinations.where((c) => c.isDrawable).length;
print('flag combinations: ${combinations.length}, drawable: $drawable');
final screens = [const Loading(), const Ready('ada'), const Failed('offline')]
.map(screenFor)
.toList();
print('sealed states: $screens');
test('three fields allow eight combinations', combinations.length, 8);
test('only three of them are a screen you have designed', drawable, 3);
test('one of the rest is "nothing has happened yet", and four are nonsense',
combinations.length - drawable - 1, 4);
test('a sealed type allows exactly the three, and the switch must cover them',
screens, ['spinner', 'content: ada', 'retry: offline']);
}
What it printed, when the build ran it
flag combinations: 8, drawable: 3 sealed states: [spinner, content: ada, retry: offline] ok three fields allow eight combinations ok only three of them are a screen you have designed ok one of the rest is "nothing has happened yet", and four are nonsense ok a sealed type allows exactly the three, and the switch must cover them
This is the strongest single thing you can say about async in an interview: the reason
AsyncValue and sealed Bloc states matter is not ergonomics, it is that they
make the illegal combinations unrepresentable and the legal ones impossible to forget.
The switch above does not compile if you drop an arm. Most "we forgot the
error state" bugs are a type system that was never asked.
The framework, five questions
- How many people, and how long? Two people for six months: whatever you both know. Twelve people for four years: pick the one with a single obvious way to do things, and Bloc is that one. Consistency beats elegance at scale, and it is not close.
- How much of the app is async and derived? A lot of derived values and cached fetches is Riverpod's case, and its strongest one. A few screens each owning a state machine is Bloc's.
- What does a test look like? Ask it about the actual code. If your
state machines have interesting sequences,
bloc_test's "given these events, expect these states" is worth a lot. If your complexity is in the wiring, a provider container with overrides is worth more. - Boilerplate or legibility? These are the same axis seen from two ends. Bloc's verbosity is what makes a feature readable in one file by someone who did not write it. Riverpod's brevity moves that information into a graph you cannot see from the file.
- Who cleans up? Every option needs an answer for when a value dies.
BlocProvidercloses what it created. Riverpod hasautoDispose. PlainsetStatehasdispose. A team without an answer has a memory leak they have not found yet.
A defensible default
Something has to be the starting point, and refusing to have one reads as evasion. This one is defensible because each step has a trigger rather than a taste:
- One reader, dies with the widget —
setState. This is most state in most apps and needs no defending. - A few readers in one subtree — lift it to the common ancestor, pass it down. Still no library.
- Readers across the app, or outliving a route — now you need delivery, and this is where the library earns its place.
- Sequences that matter, or a flow you need to audit — events and a reducer. Bloc, or a hand-rolled equivalent if you already have one.
- A graph of derived and cached values — Riverpod, and use
autoDisposefrom the first day rather than adding it after the leak.
The trap in the question. "Which do you prefer?" invites a declaration, and a declaration is the weakest available answer. So is the diplomatic non-answer — "it depends" with nothing after it reads as not having decided anything ever. The shape that works is a decision with its reason and its cost: "Bloc on the last one. Team of nine, three of them new to Flutter, and the checkout flow needed an audit trail. It cost us maybe forty per cent more code in the simple features, which I would take again for that team. On a two-person app I would have used Riverpod, because most of that app was cached reads." That is a defence, not a preference.
Two more sentences worth having ready. On migration: you do not need to pick one for the whole app, because these libraries coexist perfectly well, and an incremental move feature-by-feature is usually the honest plan. And on the thing underneath all of it: none of them removes the ownership question from lesson 19. They deliver state to the widgets that read it. Deciding which widgets those are is still your job, and it is the part that actually determines whether the app is fast.