Emmanuel Akinfulubi

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 same list, with the tick owned one level apart

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// State too low: each row keeps its own tick, and the row is inside a builder
/// list, so the framework may destroy it the moment it scrolls out of view.
class TooLowRow extends StatefulWidget {
  const TooLowRow({super.key, required this.n});
  final int n;

  @override
  State<TooLowRow> createState() => _TooLowRowState();
}

class _TooLowRowState extends State<TooLowRow> {
  bool picked = false;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 60,
      child: TextButton(
        onPressed: () => setState(() => picked = !picked),
        child: Text('row ${widget.n} ${picked ? 'picked' : 'clear'}'),
      ),
    );
  }
}

/// The same list with the ticks owned by the list itself. The rows are now told
/// what they are, rather than remembering it.
class OwnedList extends StatefulWidget {
  const OwnedList({super.key});

  @override
  State<OwnedList> createState() => _OwnedListState();
}

class _OwnedListState extends State<OwnedList> {
  final picked = <int>{};

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 40,
      itemBuilder: (context, i) => SizedBox(
        height: 60,
        child: TextButton(
          onPressed: () => setState(() {
            if (!picked.remove(i)) picked.add(i);
          }),
          child: Text('row $i ${picked.contains(i) ? 'picked' : 'clear'}'),
        ),
      ),
    );
  }
}

void main() {
  testWidgets('state below the list dies when the row scrolls away', (tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: ListView.builder(itemCount: 40, itemBuilder: (context, i) => TooLowRow(n: i)),
      ),
    );

    await tester.tap(find.text('row 0 clear'));
    await tester.pump();
    expect(find.text('row 0 picked'), findsOneWidget);

    await tester.drag(find.byType(ListView), const Offset(0, -2000));
    await tester.pumpAndSettle();
    expect(find.text('row 0 picked'), findsNothing);

    await tester.drag(find.byType(ListView), const Offset(0, 2000));
    await tester.pumpAndSettle();
    expect(find.text('row 0 clear'), findsOneWidget, reason: 'the tick died with the row');
  });

  testWidgets('state owned by the list survives the same scroll', (tester) async {
    await tester.pumpWidget(const MaterialApp(home: OwnedList()));

    await tester.tap(find.text('row 0 clear'));
    await tester.pump();
    expect(find.text('row 0 picked'), findsOneWidget);

    await tester.drag(find.byType(ListView), const Offset(0, -2000));
    await tester.pumpAndSettle();
    await tester.drag(find.byType(ListView), const Offset(0, 2000));
    await tester.pumpAndSettle();
    expect(find.text('row 0 picked'), findsOneWidget, reason: 'the owner still had it');
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

One tap, two rebuild footprints

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

final builds = <String, int>{};
void note(String who) => builds[who] = (builds[who] ?? 0) + 1;

/// A panel that never reads the quantity.
class Settings extends StatelessWidget {
  const Settings({super.key, required this.name});
  final String name;

  @override
  Widget build(BuildContext context) {
    note(name);
    return Text(name);
  }
}

/// Too high: the quantity lives above MaterialApp, so one tap rebuilds the app.
class TooHigh extends StatefulWidget {
  const TooHigh({super.key});

  @override
  State<TooHigh> createState() => _TooHighState();
}

class _TooHighState extends State<TooHigh> {
  int quantity = 0;

  @override
  Widget build(BuildContext context) {
    note('high.app');
    return MaterialApp(
      home: Column(
        children: [
          Settings(name: 'high.settings'),
          Text('total ${quantity * 250}p'),
          TextButton(onPressed: () => setState(() => quantity++), child: const Text('high +')),
        ],
      ),
    );
  }
}

/// Right place: the quantity lives in the smallest widget that contains both the
/// stepper and the total that reads it.
class JustRight extends StatelessWidget {
  const JustRight({super.key});

  @override
  Widget build(BuildContext context) {
    note('right.app');
    return MaterialApp(
      home: Column(
        children: [
          Settings(name: 'right.settings'),
          const Basket(),
        ],
      ),
    );
  }
}

class Basket extends StatefulWidget {
  const Basket({super.key});

  @override
  State<Basket> createState() => _BasketState();
}

class _BasketState extends State<Basket> {
  int quantity = 0;

  @override
  Widget build(BuildContext context) {
    note('right.basket');
    return Column(
      children: [
        Text('total ${quantity * 250}p'),
        TextButton(onPressed: () => setState(() => quantity++), child: const Text('right +')),
      ],
    );
  }
}

void main() {
  testWidgets('too high rebuilds the app; the right place rebuilds one widget', (tester) async {
    builds.clear();

    await tester.pumpWidget(const TooHigh());
    await tester.tap(find.text('high +'));
    await tester.pump();
    expect(builds['high.app'], 2, reason: 'MaterialApp and everything under it');
    expect(builds['high.settings'], 2, reason: 'it never read the quantity');

    await tester.pumpWidget(const JustRight());
    await tester.tap(find.text('right +'));
    await tester.pump();
    // {high.app: 2, high.settings: 2, right.app: 1, right.settings: 1, right.basket: 2}
    debugPrint('builds: $builds');
    expect(builds['right.app'], 1, reason: 'the app was never rebuilt');
    expect(builds['right.settings'], 1, reason: 'the panel was never rebuilt');
    expect(builds['right.basket'], 2);
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

The rule, applied mechanically

ran and analysed
// 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. updateShouldNotify decides 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 what of(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

A hand-built InheritedWidget, and who it wakes

passed flutter test
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

int dependentBuilds = 0;
int bystanderBuilds = 0;

/// The whole of an InheritedWidget: a value, a lookup, and one decision about
/// whether a change is worth telling anybody about.
class Scale extends InheritedWidget {
  const Scale({super.key, required this.factor, required super.child});
  final double factor;

  static Scale of(BuildContext context) {
    final found = context.dependOnInheritedWidgetOfExactType<Scale>();
    if (found == null) throw FlutterError('no Scale above this widget');
    return found;
  }

  @override
  bool updateShouldNotify(Scale old) => old.factor != factor;
}

/// Asks for the value, so it is registered as a dependent.
class Dependent extends StatelessWidget {
  const Dependent({super.key});

  @override
  Widget build(BuildContext context) {
    dependentBuilds++;
    return Text('factor ${Scale.of(context).factor}');
  }
}

/// Never asks, so it is never told.
class Bystander extends StatelessWidget {
  const Bystander({super.key});

  @override
  Widget build(BuildContext context) {
    bystanderBuilds++;
    return const Text('bystander');
  }
}

class Host extends StatefulWidget {
  const Host({super.key});

  @override
  State<Host> createState() => _HostState();
}

class _HostState extends State<Host> {
  double factor = 1;
  int churn = 0;

  @override
  Widget build(BuildContext context) {
    return Scale(
      factor: factor,
      child: Column(
        children: [
          Text('churn $churn'),
          TextButton(onPressed: () => setState(() => churn++), child: const Text('churn')),
          TextButton(onPressed: () => setState(() => factor += 1), child: const Text('bigger')),
          const Dependent(),      // const, so only a dependency change can wake it
          const Bystander(),
        ],
      ),
    );
  }
}

void main() {
  testWidgets('only the widgets that asked are rebuilt', (tester) async {
    dependentBuilds = 0;
    bystanderBuilds = 0;

    await tester.pumpWidget(const MaterialApp(home: Host()));
    expect(dependentBuilds, 1);
    expect(bystanderBuilds, 1);

    // Rebuild the Host without touching the factor. updateShouldNotify says no.
    await tester.tap(find.text('churn'));
    await tester.pump();
    expect(find.text('churn 1'), findsOneWidget);
    expect(dependentBuilds, 1, reason: 'updateShouldNotify returned false');

    // Now change the factor.
    await tester.tap(find.text('bigger'));
    await tester.pump();
    // dependent: 2  bystander: 1
    debugPrint('dependent: $dependentBuilds  bystander: $bystanderBuilds');
    expect(find.text('factor 2.0'), findsOneWidget);
    expect(dependentBuilds, 2, reason: 'the dependent was rebuilt on its own');
    expect(bystanderBuilds, 1, reason: 'the bystander never asked, so it was left alone');
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

The dependency set, and the one call that consults it

ran and analysed
// 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 the StatefulWidget.
  • flutter_bloc is that pairing plus a stream, with BlocProvider as the inherited part and BlocBuilder as the subscriber.
  • Riverpod puts one InheritedWidget at 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.

A Cubit from scratch, with the emit guard proved

ran and analysed
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.

The Cubit driving a screen, with the rebuild scope measured

passed flutter test
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// The same hand-built Cubit as above, trimmed to one type.
class CounterCubit {
  int _state = 0;
  final _out = StreamController<int>.broadcast();

  int get state => _state;
  Stream<int> get stream => _out.stream;

  void increment() {
    _state++;
    _out.add(_state);
  }

  Future<void> close() => _out.close();
}

int viewBuilds = 0;
int labelBuilds = 0;

class CounterView extends StatelessWidget {
  const CounterView({super.key, required this.cubit});
  final CounterCubit cubit;

  @override
  Widget build(BuildContext context) {
    viewBuilds++;
    return MaterialApp(
      home: Column(
        children: [
          StreamBuilder<int>(
            stream: cubit.stream,
            initialData: cubit.state,
            builder: (context, snap) {
              labelBuilds++;
              return Text('count ${snap.data}');
            },
          ),
          TextButton(onPressed: cubit.increment, child: const Text('add')),
        ],
      ),
    );
  }
}

void main() {
  testWidgets('a Cubit plus a StreamBuilder is the whole of BlocBuilder', (tester) async {
    viewBuilds = 0;
    labelBuilds = 0;
    final cubit = CounterCubit();
    addTearDown(cubit.close);

    await tester.pumpWidget(CounterView(cubit: cubit));
    expect(find.text('count 0'), findsOneWidget);

    await tester.tap(find.text('add'));
    await tester.pump();
    expect(find.text('count 1'), findsOneWidget);

    await tester.tap(find.text('add'));
    await tester.pump();
    // view: 1  label: 3
    debugPrint('view: $viewBuilds  label: $labelBuilds');
    expect(find.text('count 2'), findsOneWidget);
    expect(cubit.state, 2);
    expect(viewBuilds, 1, reason: 'nothing above the StreamBuilder rebuilt');
    expect(labelBuilds, 3, reason: 'the builder ran per state, plus the first frame');
  });
}

Result

This ran as a real widget test during the build, and passed.

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.

A Bloc from scratch, and the audit trail events buy

ran and analysed
// 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's EventTransformer, 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.

The real flutter_bloc API, shown for recognition

shown, not run
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 final that carries its own type. There is no string to mistype and no type to get wrong — a mismatch is a compile error, not a ProviderNotFoundException at run time.
  • No BuildContext needed. 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 container from scratch: cache, edges and invalidation

ran and analysed
// 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.

This does not compile, on purpose

does not compile
// 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

The real Riverpod API, shown for recognition

shown, not run
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. FutureProvider plus AsyncValue gives 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_test asserts 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 autoDispose and family are a genuinely better answer than "put the provider above the route and hope", but they are also the part people get wrong. A missing autoDispose is 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.

Where the value is read, not which package delivered it

ran and analysed
// 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.

Eight combinations, three screens, and the four that should not exist

ran and analysed
/// 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. BlocProvider closes what it created. Riverpod has autoDispose. Plain setState has dispose. 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 autoDispose from 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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.