BLoC & Patterns
One direction, always
Section titled “One direction, always”BLoC (Business Logic Component) is a pattern built on a strict rule: data flows in one direction. The UI sends events (or method calls) in; the BLoC emits states out; the UI rebuilds from those states. The UI never mutates state directly, and business logic never touches widgets.
sequenceDiagram participant UI participant Bloc UI->>Bloc: add(Event) / method call Bloc->>Bloc: run business logic Bloc->>UI: emit(new State) UI->>UI: rebuild from State
This one-way discipline is the whole value. Because state only ever changes in one place, in response to explicit inputs, the app becomes predictable and testable — you can feed events to a BLoC in a test and assert the exact sequence of states, with no widgets involved.
The flutter_bloc package (current: 9.1.1 on pub.dev) ships both flavors covered in this lesson, plus bloc_test (10.0.0) for the blocTest helper and equatable (3.0.0) for cheap value equality on events/states — all confirmed on pub.dev and used as the pinned versions below.
Cubit: the simpler half
Section titled “Cubit: the simpler half”Cubit is the simpler flavor — no event classes, you just call methods that emit new states. Start here.
import 'package:flutter_bloc/flutter_bloc.dart';
/// Cubit: the simpler half of the library — no event classes, methods emit/// states directly.class CounterCubit extends Cubit<int> { CounterCubit() : super(0);
void increment() => emit(state + 1); void reset() => emit(0);}// In the UI: BlocBuilder rebuilds when a new state is emitted.BlocBuilder<CounterCubit, int>( builder: (context, count) => Text('$count'),);
// Trigger an input:context.read<CounterCubit>().increment();BLoC proper adds explicit Event classes between the UI and the logic (add(Increment()) instead of increment()). That indirection buys you a documented, loggable list of every possible input — valuable in large apps, overkill in small ones.
Sealed event/state unions, proven exhaustive by the analyzer
Section titled “Sealed event/state unions, proven exhaustive by the analyzer”Real BLoC code models events and states as sealed class unions (Dart 3) — every possible event/state variant is a known, closed set, and a switch over them is checked for completeness at compile time, not caught later at runtime.
import 'package:equatable/equatable.dart';import 'package:flutter_bloc/flutter_bloc.dart';
sealed class CounterEvent { const CounterEvent();}
final class Increment extends CounterEvent { const Increment();}
final class Decrement extends CounterEvent { const Decrement();}
final class Reset extends CounterEvent { const Reset();}
sealed class CounterState extends Equatable { const CounterState(this.value); final int value;
@override List<Object?> get props => [value];}
final class CounterValue extends CounterState { const CounterValue(super.value);}
/// A distinct state variant — not just "CounterValue(5)" — so UI can branch/// on the TYPE, not re-check a magic number everywhere.final class CounterAtMax extends CounterState { const CounterAtMax(super.value);}
const _max = 5;
class CounterBloc extends Bloc<CounterEvent, CounterState> { CounterBloc() : super(const CounterValue(0)) { on<CounterEvent>((event, emit) { final next = switch (event) { Increment() => state.value + 1, Decrement() => state.value - 1, Reset() => 0, }; emit(next >= _max ? CounterAtMax(_max) : CounterValue(next)); }); }}CounterState extends Equatable with props = [value] is what makes bloc_test’s expect: () => [...] (below) compare emitted states by value, not by identity — without it, two separately-constructed CounterValue(1) instances would be unequal and every blocTest assertion would fail on a technicality.
Now the exhaustiveness proof: delete the Reset() case from that same switch and run flutter analyze — the real, unedited diagnostic:
// @expect-error missing `Reset()` case — real non_exhaustive_switch_expression, proven belowint handle(CounterEvent event, int current) { return switch (event) { Increment() => current + 1, Decrement() => current - 1, };}error • The type 'CounterEvent' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Reset()'. Try adding a wildcard pattern or cases that match 'Reset()' • non_exhaustive_switch_expressionThis is the real payoff of sealed: add a fourth event class later (say SetTo(int value)), and every switch over CounterEvent anywhere in the codebase that doesn’t handle it fails flutter analyze immediately — not “someone eventually notices the UI doing nothing for the new event in production.”
BlocProvider, BlocBuilder with buildWhen, BlocListener
Section titled “BlocProvider, BlocBuilder with buildWhen, BlocListener”BlocProvider puts a BLoC/Cubit in the widget tree (it’s InheritedWidget-based, same mechanism as the InheritedWidget lesson) so context.read/BlocBuilder/BlocListener below it can find it. BlocBuilder rebuilds on every new state by default — buildWhen narrows that, exactly like InheritedModel’s aspects or Riverpod’s .select(): return false to skip a rebuild for a state change your UI doesn’t actually care about.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:my_app/counter_bloc.dart';
int capBannerBuildCount = 0;final capReachedLog = <int>[];
/// Only cares about whether we've hit the cap, not the running count — so/// buildWhen skips every intermediate increment and rebuilds ONLY when the/// state's runtime TYPE actually changes (CounterValue <-> CounterAtMax).class CapBanner extends StatelessWidget { const CapBanner({super.key}); @override Widget build(BuildContext context) { return BlocBuilder<CounterBloc, CounterState>( buildWhen: (previous, current) => previous.runtimeType != current.runtimeType, builder: (context, state) { capBannerBuildCount++; return state is CounterAtMax ? const Text('capped') : const SizedBox.shrink(); }, ); }}
/// A side effect (logging here; in a real app: SnackBar, navigation) that/// fires once when the cap is reached, without itself rebuilding anything.class CapListener extends StatelessWidget { const CapListener({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context) { return BlocListener<CounterBloc, CounterState>( listener: (context, state) { if (state is CounterAtMax) capReachedLog.add(state.value); }, child: child, ); }}import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/counter_bloc.dart';import 'package:my_app/counter_bloc_widgets.dart';
void main() { testWidgets('buildWhen skips rebuilds while the state TYPE stays the same', (tester) async { capBannerBuildCount = 0; final bloc = CounterBloc(); addTearDown(bloc.close);
await tester.pumpWidget( MaterialApp( home: BlocProvider.value(value: bloc, child: const CapBanner()), ), ); expect(capBannerBuildCount, 1, reason: 'initial build always happens');
// 0 -> 1 -> 2 -> 3 -> 4: all stay CounterValue, buildWhen returns false — // no rebuild for any of these four. for (var i = 0; i < 4; i++) { bloc.add(const Increment()); await tester.pump(); } expect(capBannerBuildCount, 1, reason: 'runtimeType never changed — buildWhen skipped every rebuild'); expect(find.text('capped'), findsNothing);
// 4 -> 5: CounterValue -> CounterAtMax, runtimeType changes — rebuilds. bloc.add(const Increment()); await tester.pump(); expect(capBannerBuildCount, 2, reason: 'runtimeType changed — buildWhen allowed exactly one rebuild'); expect(find.text('capped'), findsOneWidget); });
testWidgets('BlocListener fires exactly once when the cap is reached, without rebuilding', (tester) async { capReachedLog.clear(); final bloc = CounterBloc(); addTearDown(bloc.close);
await tester.pumpWidget( MaterialApp( home: BlocProvider.value( value: bloc, child: CapListener(child: const SizedBox.shrink()), ), ), ); expect(capReachedLog, isEmpty);
for (var i = 0; i < 5; i++) { bloc.add(const Increment()); await tester.pump(); }
expect(capReachedLog, [5], reason: 'listener fired exactly once, with the capped value'); });}✓ buildWhen skips rebuilds while the state TYPE stays the same✓ BlocListener fires exactly once when the cap is reached, without rebuilding
00:00 +2: All tests passed!Four Increment events land four new CounterValue states, and capBannerBuildCount never moves — proof that buildWhen genuinely prevents the rebuild, not just “the output happens to look the same.” Only the fifth event, the one that actually changes the state’s runtime type, causes a second build.
bloc_test’s blocTest: asserting the exact emitted sequence
Section titled “bloc_test’s blocTest: asserting the exact emitted sequence”blocTest builds the bloc, drives it with act, and asserts the full ordered list of emitted states with expect — no widget involved at all, exactly the “predictable and testable” payoff from the unidirectional rule.
import 'package:bloc_test/bloc_test.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/counter_bloc.dart';
void main() { group('CounterBloc', () { blocTest<CounterBloc, CounterState>( 'emits [CounterValue(1)] when Increment is added once', build: CounterBloc.new, act: (bloc) => bloc.add(const Increment()), expect: () => [const CounterValue(1)], );
blocTest<CounterBloc, CounterState>( 'emits CounterAtMax once the count reaches 5', build: CounterBloc.new, act: (bloc) { for (var i = 0; i < 5; i++) { bloc.add(const Increment()); } }, expect: () => [ const CounterValue(1), const CounterValue(2), const CounterValue(3), const CounterValue(4), const CounterAtMax(5), ], );
blocTest<CounterBloc, CounterState>( 'Reset emits CounterValue(0) regardless of prior state', build: CounterBloc.new, seed: () => const CounterAtMax(5), act: (bloc) => bloc.add(const Reset()), expect: () => [const CounterValue(0)], ); });}✓ CounterBloc emits [CounterValue(1)] when Increment is added once✓ CounterBloc emits CounterAtMax once the count reaches 5✓ CounterBloc Reset emits CounterValue(0) regardless of prior state
00:00 +3: All tests passed!seed: starts the bloc from a specific state without replaying every event that would normally lead there — essential for testing “what happens next from state X” in isolation. expect: () => [...] compares by value thanks to Equatable; without it, this whole file would fail even though the logic is correct.
dependencies: flutter_bloc: ^9.1.1 equatable: ^3.0.0
dev_dependencies: bloc_test: ^10.0.0When the ceremony pays off
Section titled “When the ceremony pays off”BLoC is more structure than setState or Provider, and that structure is a cost you pay upfront. It earns back when:
- The app is large and you want business logic in testable units, fully separate from UI — proven above: the entire
CounterBlocstate machine is asserted with zero widgets. - State transitions are complex — many events, async flows, distinct state variants (not just field changes) — and you want them explicit, exhaustively checked, and traceable.
- A team benefits from one enforced pattern across the codebase, where every possible input is a named, discoverable class instead of an ad hoc method call.
For a small app or a single screen’s local state, BLoC is over-engineering — setState or a simple ChangeNotifier is clearer. The skill is matching the ceremony to the complexity.
Mental Model
Section titled “Mental Model”A BLoC is a pure function machine wearing a stream: (currentState, event) -> newState, with emit as the only way out and add as the only way in. Cubit strips the event layer off that machine — the “input” is just “which method got called” — trading traceability for less boilerplate. sealed on the event/state hierarchies is what turns “we think we handled every case” into “the compiler refuses to build until we do.” buildWhen/listenWhen are the same idea as InheritedModel aspects or Riverpod’s .select(), applied to BLoC’s stream of states instead of a provider’s value.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Cubit | Least ceremony — methods that emit | No event log/traceability; harder to see “what inputs are possible” from the type alone |
| Full BLoC with sealed events | Every input is a named, exhaustively-checked class | More boilerplate per feature — event classes, a switch/on<T> handler |
| Sealed state union (multiple variants) | UI branches on type, not on magic values buried in one field | More classes than “one state class with a bunch of nullable fields” |
Equatable on states/events | blocTest/BlocBuilder compare by value, not identity | One more base class and a props list to keep in sync with fields |
buildWhen/listenWhen | Skip rebuilds/side effects the UI doesn’t care about, proven above | Another predicate to get right — a bug here silently suppresses real updates |
Common Misconceptions
Section titled “Common Misconceptions”- “Cubit and Bloc are unrelated APIs.”
Bloc<Event, State>is aCubit<State>that additionally accepts a stream of typed events instead of direct method calls — the state-emission machinery underneath is the same. - “A
switchover a sealed class is exhaustive automatically, no matter how it’s written.” Proven above: the analyzer genuinely refuses to compile aswitchexpression that’s missing a case, with a specific, real diagnostic (non_exhaustive_switch_expression) — but this protection only exists because the class hierarchy issealed; a plainabstract classgets no such check. - “buildWhen just optimizes performance, the UI behaves the same either way.” Proven above:
capBannerBuildCountstaying flat across four events is the whole functional point, not a micro-optimization — if the builder had a side effect,buildWhen: falsegenuinely skips it. - “Without Equatable, states just compare a little slower.” Proven implicitly above: without
Equatable,blocTest’sexpect: () => [CounterValue(1)]would fail on identity inequality even though the emitted state has the exact right value — this is a correctness issue for tests, not a performance one. - “BLoC is always the right choice for ‘serious’ state management.” The lesson’s own trade-off table and “when the ceremony pays off” section say the opposite: for a single screen’s simple state, the extra classes are pure overhead with no payoff.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated BLoC code tends to get the shape right (sealed events, a Bloc<Event, State>) but skip the two things that only matter once the feature grows: Equatable (silently breaks every equality-based test and buildWhen/listenWhen comparison) and actually widening the switch to a real exhaustive form instead of a chain of if (event is X) that the analyzer can’t check for completeness.
// AI-generated: "handle the counter events"class CounterBloc extends Bloc<CounterEvent, CounterState> { CounterBloc() : super(const CounterValue(0)) { on<CounterEvent>((event, emit) { if (event is Increment) { emit(CounterValue(state.value + 1)); } else if (event is Decrement) { emit(CounterValue(state.value - 1)); } // Reset silently does nothing — no compiler warning, because this is // an if/else chain, not a switch over a sealed type. }); }}Before trusting generated BLoC/Cubit code, check for:
- A
switch (event) { ... }(orswitch (state) { ... }) over the sealed hierarchy, not anif (event is X) ... else if (event is Y) ...chain — only theswitchform gets real exhaustiveness checking from the analyzer, proven above. Equatable(or manual==/hashCode) on every state and event class that will ever be compared — in a test’sexpect: () => [...], inbuildWhen, or inBlocListener’s default equality-based dedup.buildWhen/listenWhenpredicates that actually reference the fields/types they claim to gate on, not a stub that always returnstrue(defeats the purpose) or alwaysfalse(UI never updates).- Every event class the
sealedhierarchy declares is actually handled somewhere — the analyzer only catches this inside a realswitch, so a generated handler that silently drops an event (likeResetabove) ships clean if it never usesswitchin the first place.
🐛 Spot the bug
sealed class AuthState {}final class Unauthenticated extends AuthState {}final class Authenticated extends AuthState {Authenticated(this.userId);final String userId;}final class AuthError extends AuthState {AuthError(this.message);final String message;}
class AuthCubit extends Cubit<AuthState> {AuthCubit() : super(Unauthenticated());
String statusLabel() => switch (state) { Authenticated(:final userId) => 'Signed in as $userId', Unauthenticated() => 'Signed out',};}Show the answer
This does not compile: AuthState has three sealed subtypes (Unauthenticated, Authenticated, AuthError), but the switch expression in statusLabel() only covers two of them — the real analyzer error is non_exhaustive_switch_expression, saying exactly what’s missing: "doesn't match the pattern 'AuthError()'". This is the exact class of bug proven earlier in this lesson with CounterEvent. The fix is adding the missing case (AuthError(:final message) => 'Error: \$message') — and because the hierarchy is sealed, the compiler will force this same fix on every other switch over AuthState in the codebase too, not just this one.