BLoC moves business logic out of widgets: the UI sends input in, the bloc emits immutable states out, and widgets rebuild from the latest state. A Cubit takes input as method calls; a Bloc takes it as event objects handled by on<Event>.
**Cubit**: public methods call emit(newState); the least code, enough for most screens
**Bloc**: every input is an event class, so each change has a named cause that BlocObserver.onTransition can log
**Only Bloc has event transformers: debounce, drop, sequence
Which one**: start with a Cubit; switch to a Bloc when inputs race each other or you need event-level tracing. The switch is mechanical, the UI barely changes.
FlutterMiddle
State with BLoC
BLoC pattern, Cubit, Events, States, BlocBuilder, BlocConsumer
Questions closed
0 / 22
- 0Review
- 4Asked often
Questions
All four subscribe to the same bloc: BlocBuilder and BlocSelector rebuild UI, BlocListener runs side effects without rebuilding, and BlocConsumer does both. buildWhen and listenWhen take (previous, current) and return whether this change should reach the builder or the listener.
**BlocBuilder**: rebuilds on every state change; buildWhen narrows that to the changes the widget cares about
**BlocSelector**: picks one value out of the state and rebuilds only when that value changes by ==; the simplest way to read a single field
**BlocListener**: called once per state change for navigation, a snackbar or a dialog; its child is not rebuilt by it
**BlocConsumer**: a builder and a listener on one bloc, each with its own buildWhen and listenWhen
**Why effects never go in builder**: Flutter can call builder many times for one state (a parent rebuild, a theme change), so a push or a snackbar there repeats
**MultiBlocListener**: flattens several listeners the way MultiBlocProvider flattens providers
BlocSelector rebuilds when the selected value is not == to the previous one, and this selector returns a new List from .toList() every time. Dart lists compare by identity, so every state change, even a new search query, rebuilds the whole list.
How the selector decides: after each state change it calls selector and compares the result with the last one by ==; equal means skip the build. A fresh list is never equal, so the widget behaves like a plain BlocBuilder.
The fix: select something that keeps its identity or value: state.todos is the same instance until the todos really change (immutable state plus copyWith), and the filtering moves into builder. A count, a bool or an Equatable object works too.
Same rule elsewhere: context.select compares the same way, so any selector that builds a list, map or set with where, map or a spread rebuilds on every change.
A derived list you need often: store it in the state as a field the cubit computes, or switch to BlocBuilder and compare the lists with ListEquality from package:collection in buildWhen.
How to catch it: the rebuild tracking in Flutter DevTools, or a widget test that changes only the query and counts builder calls.
BlocProvider(create:) builds a bloc and owns it: the bloc is closed when that provider leaves the tree. BlocProvider.value lends an instance someone else owns and never closes it, which is exactly what a pushed route or a dialog needs.
Why a pushed page cannot find the bloc: the page is mounted under the Navigator, not under the screen that pushed it, and context.read only walks ancestors; you get a ProviderNotFoundException ("Could not find the correct Provider<CartBloc>"); BlocProvider.of says "BlocProvider.of() called with a context that does not contain a CartBloc"
**The route builder's context is the route's own**: so context.read<CartBloc>() inside MaterialPageRoute(builder: ...) fails too; read it before the push
The fix: final cart = context.read<CartBloc>(); then BlocProvider.value(value: cart, child: DetailsPage()); dialogs and bottom sheets work the same way
**Never wrap an existing bloc in create:: the page would close a bloc it does not own when it pops
Shared by many routes**: hoist the provider above MaterialApp or into a go_router ShellRoute instead of passing it along
**MultiBlocProvider**: a flat list instead of nested providers; entries are nested in list order, so a later create can read an earlier one
emit drops a new state that is == to the current one (after the first emit), and BlocSelector, buildWhen and bloc_test compare with == too. Without value equality == means identity, so the same data in a new object always counts as a new state.
Duplicate rebuilds and effects: reloading identical data rebuilds every BlocBuilder and fires every BlocListener again, so one result can show its snackbar twice
Tests fail: expect: () => [ProfileLoaded(user)] never matches, because the expected and the emitted states are different objects
How to get it: extend Equatable and list every field in props, generate it with freezed, or write == and hashCode by hand; equatable and freezed compare lists and maps by content
Nested models need it too: props: [user] only helps if User itself has value equality
The flip side: a field missing from props, or a list mutated in place, makes a real change look equal and the emit vanishes
Same state on purpose: to fire the same error twice in a row, put a counter or timestamp into the state
emit skips a state that is == to the current one. state.items.add(item) mutates the list the current state already holds and emit(state) passes that same instance, so every emit after the first is dropped and nothing rebuilds.
Why the first tap seems to work: bloc lets the first emit through even when it equals the initial state, so the first mutated list is painted; from the second tap emit returns early
A new wrapper does not help: emit(CartState(state.items)) is a new object around the same mutated list; Equatable compares the list contents, both sides hold that one list, so they are still equal
The fix: build a new list: emit(CartState([...state.items, item])), and keep state lists unmodifiable (List.unmodifiable, or freezed) so an in-place mutation throws instead of failing silently
Other reasons an emit seems lost: a field missing from props, a buildWhen or BlocSelector that filters the change out, or a widget reading a different instance created by a second BlocProvider(create:)
Diagnose fast: log onEvent and onChange in a BlocObserver: an event with no change means equality ate it; no event at all means the widget talks to another instance
BlocProvider is lazy by default: create does not run until something first reads the bloc, so a bloc that subscribes in its constructor does not exist until Settings reads it.
What triggers creation: the first context.read, context.watch, BlocProvider.of or BlocBuilder below the provider. Sitting in the tree or being listed in a MultiBlocProvider does not.
**Fix: lazy: false: for blocs whose job starts before any widget asks: session, connectivity, push tokens, remote config
For screen blocs keep the default**: and start them explicitly with ..add(const FeedStarted())
**BlocProvider.value is never lazy: it takes an instance you already built, and it does not close it either
A symptom, not a mystery**: a bloc that was never created is never closed, so a close() breakpoint that never fires says the same thing
The one-shot effect is stored as an ordinary field in the state, so every later state still carries it and BlocListener delivers it again.
Why it repeats: the listener runs on every state change; toggling the filter emits a state that differs in filter but still holds message: 'Saved'
It survives remounting too: after a route pop or a tab switch the listener's first change carries the stale field, and hydrated_bloc even writes it to disk
Fix 1: consume it: show it in listener, then add an event whose handler emits a state with message cleared
Fix 2: keep effects out of the state: expose a separate Stream<FeedEffect> from the bloc, subscribed in initState and cancelled in dispose
The test: if a field would be wrong when the same state is rendered a second time, it is an effect, not state
add only puts the event on the bloc's event stream. The handler runs a microtask later at the earliest, so on the next line nothing has emitted yet.
The pipeline: add pushes the event into a StreamController; the transformer picks it up, and a debounce can hold it for seconds; the handler runs and emit updates state synchronously; BlocBuilder is notified one microtask after that
**So state is fresh right after emit inside the handler**: and stale right after add at the call site
Why bloc is built this way: the queue is what makes transformers, ordering and BlocObserver possible
Write it as a listener instead: BlocListener with listenWhen, popping when status turns success; that listener is the callback you were trying to write inline
If the call site genuinely needs a future: use a Cubit method returning Future<void>; await bloc.stream.firstWhere(...) works but misses states emitted before you subscribe and hangs if the predicate never matches
The handler returned before its work finished. An Emitter is valid only until the future the handler returns completes, and the callback called emit after that moment.
Here: the handler is not async and does not return the future from then, so it completes at once; the callback later emits into a dead emitter. Make it async and await the fetch.
Same bug, other shapes: a missing await, a stream.listen((v) => emit(...)) inside a handler, a Timer.periodic, a Completer someone else resolves
Tie a stream to the handler: await emit.forEach(...) or await emit.onEach(...) exist exactly for that
Cancelled is not the same as finished: after a restartable() cancel emit is a silent no-op, after normal completion it is this error; guard long loops with if (emit.isDone) return;
Debug-only assert: the message comes from an assert, so a release build does not throw and the late emit still goes through; that is how the bug reaches production as an out-of-order or post-close() emit. A Cubit has no emitter: emit after close() throws in every build, so re-check isClosed after every await.
Both handlers run: LoggedIn emits AuthLoading from the catch-all and then Authenticated from the specific one. The catch-all is a bug because it also swallows every future event that has no handler of its own.
No first-match-wins: Bloc gives every on<E> its own subscription filtered by event is E, so LoggedIn matches both on<AuthEvent> and on<LoggedIn>
Two writers, one state: the catch-all emits AuthLoading, the specific handler emits the result; each has its own emitter and they run concurrently, so if the catch-all emitted after an await, it could overwrite Authenticated
The worse bug: an event with no dedicated handler, say a TokenRefreshed added next sprint, now matches only the catch-all: the screen spins forever, and the debug check "add(TokenRefreshed) was called without a registered event handler" never fires
Registration order is not precedence: it only decides which subscription receives the event first
The same concrete type twice is rejected: a second on<LoggedIn> throws a StateError in debug: one handler per event type
The fix: logging goes to BlocObserver.onEvent, which sees every event of every bloc without joining the handler pipeline; each handler emits its own loading state
Expected failures become a state; unexpected ones become telemetry. try/catch inside the handler does the first, addError and onError do the second.
**try/catch in the handler**: translate the exception into an emitted state, emit(Failure(message)). Network errors, validation and 4xx responses belong here.
**addError(error, stackTrace)**: records an error without changing the state; it flows into onError and on to the observer. Use it sparingly, for the genuinely unexpected.
**Bloc.onError**: the last-chance hook for that one bloc, while the observer's onError is app-wide and is the right place for Crashlytics or Sentry. Always call super.onError.
Common mistake: letting a handler throw: the emit that should have happened never does, and the screen sits on a stale state
The subscription belongs inside the bloc: either owned by the emitter inside a handler, or owned by the bloc and turned into private events.
**await emit.forEach(repo.watch(), onData: ..., onError: ...)**: the shortest correct version when the bloc just mirrors the stream; the handler lives as long as the stream and bloc cancels it on close()
**await emit.onEach(...)**: same lifetime, but onData returns nothing: you call emit yourself, zero or several times per item
Or subscribe in the constructor: _sub = repo.watch().listen((d) => add(_DataReceived(d))); and cancel it in close(). Updates then go through the normal pipeline, so the observer sees them and transformers apply.
**Register that handler as restartable(): otherwise a repeated event adds a second subscription alongside the first
Never hold an Emitter past its handler**: emitting later throws
BlocObserver is one global listener for every Bloc and Cubit in the app. You assign it once (Bloc.observer = AppBlocObserver(); in main() before runApp()), and it sees everything without any bloc knowing about it.
What you can override: onCreate, onEvent (Bloc only), onChange, onTransition (Bloc only), onError, onClose
**onChange vs onTransition: a change is old state to new state; a transition also names the event that caused it, which is why a Cubit has no transitions
Use it for: logging every transition while debugging, analytics, crash reporting to Sentry or Crashlytics, and performance timing
Call super**: the base methods are empty, but the @mustCallSuper lint expects it and the code stays correct if the base class gains behaviour
Cross-cutting concerns live here: the observer sees every bloc without adding a logging handler to each one
bloc_test gives you a blocTest helper that builds a bloc, fires events at it and asserts the exact sequence of states it emits, with no widget tree involved.
The shape: build constructs the bloc with mocked dependencies, seed sets a starting state, act adds the events, expect lists the states in order
**wait: hold long enough for a debounce or throttle transformer to fire
verify: extra assertions once the states have been checked
Make the states comparable**: with equatable or freezed, otherwise expect cannot compare them; isA<T>() matchers work when only the type matters
**Mock dependencies with mocktail: stub the repository instead of hitting the network
UI effects**: cover those in a widget test, with BlocProvider.value over a fake bloc
hydrated_bloc saves a bloc's state to local storage as JSON and restores it on the next launch, so a theme, a language or a draft survives a restart.
How: extend HydratedBloc or HydratedCubit and implement fromJson and toJson; return null from toJson to skip persisting that state
Set it up once: initialise HydratedBloc.storage in main() before runApp()
Persist: theme, language, onboarding done, last filter, draft text
Do not persist: auth state (derive it from a fresh token), anything sensitive (flutter_secure_storage), or large data (a real database)
Migrations: when the state shape changes, fromJson must handle the old shape or return null to fall back to the initial state; a version field in the JSON is the cheap way
In tests: assign a fake in-memory Storage in setUp, never the real on-disk one
BLoC is the most explicit and traceable but needs the most classes, Riverpod is flexible with less ceremony, ChangeNotifier is the simplest and weakest at scale. For a chat app I would pick BLoC (streams, message ordering, transition log); Riverpod is an equally sound choice for a team that knows it.
**Provider + ChangeNotifier**: a mutable object plus notifyListeners(); almost no boilerplate, but nothing records why state changed, loading and error states are hand-rolled, and a forgotten notifyListeners() is a silent bug
Riverpod: providers declared outside the tree with compile-safe lookups, AsyncValue for loading and error, auto-dispose and family. The cost is its own concepts (ref lifecycles, code generation).
BLoC: every change goes through an event or a cubit method into an immutable state; BlocObserver logs every transition, transformers control concurrency, bloc_test asserts exact state sequences. The cost is the most classes and files per feature.
Why BLoC for chat: incoming messages are a stream (emit.forEach), sends must stay ordered (sequential()), and a transition log helps when a message "disappears"; with StreamProvider or AsyncNotifier Riverpod covers the same ground
A transformer decides how events queue up for one on<Event> handler. The default concurrent() runs them in parallel; sequential() queues, droppable() ignores new ones while busy, restartable() cancels the running one.
**concurrent(): the default: every event starts its own handler in parallel and emits can land in any order, usually wrong for a user-facing flow; safe only when the handlers are genuinely independent
sequential(): one at a time, in arrival order; for writes where order matters, like cart updates or a save chain
droppable(): ignore new events while one is in flight; the Submit button that must not fire twice
restartable(): cancel the running handler and start again with the new event; search-as-you-type and autocomplete
Gotcha**: a cancelled handler's emitter is dead, so in a long chain check emit.isDone before emitting
Debounce goes in front: debounce the event stream first, then route it into restartable(); in code, debounceTime(d).switchMap(mapper) gives the restartable behaviour
current is a snapshot of state taken before the await. When two add calls overlap, both build their list from the same old snapshot, so the call that finishes last overwrites the other and one item silently disappears from the cart.
Why it happens: a Cubit does not queue method calls: the second add starts while the first is suspended at await, and both captured the same state.items. Whichever response arrives last wins, a classic lost update.
The fix: read state after the await, right where you emit, so each update builds on the latest list. Between that read and the synchronous emit no other code can run, so nothing slips in.
Why it is intermittent: it needs two add calls in flight at once, which is a fast double tap or a slow network; one at a time works. Whichever finishes second drops the other's item, in any response order. The backend did add both, so a reload shows the right cart.
If you also need requests serialised: move to a Bloc with sequential() from bloc_concurrency so overlapping adds queue up; droppable() fits a submit button where a second tap is meant to be ignored, not an add-to-cart.
How to catch it: a blocTest whose act calls add twice without awaiting, with a mock that delays each response, expecting both items in the final state.
Never let one bloc call another directly; that makes each untestable without the other. Something outside them should do the coordinating.
A shared repository, best for app-wide signals: both blocs depend on a service exposing a stream of changes; each reacts on its own and neither knows the other exists. Session, connectivity, sync status.
**A BlocListener in the widget, best for one screen**: on a specific transition of A it calls context.read<B>().add(...); the widget orchestrates and both blocs stay ignorant
**Subscribing to the other bloc's stream, last resort**: one bloc listens to another and adds its own private events; tighter coupling, and you must cancel the subscription in close()
The rule: repository for app-wide signals, listener for screen-level coordination, stream subscription only when neither fits
Never: import bloc B inside bloc A and call its methods
Keep the data and the status in one state object, so a refresh can flip the status without throwing the items away.
One class with a status field: status, posts, error in a single immutable class with copyWith. A refresh is state.copyWith(status: FeedStatus.loading), and the UI keeps rendering state.posts behind a small spinner.
**A sealed union works too if the loaded state carries an isRefreshing flag**: a plain Loading carries no items, so a refresh paints an empty screen
How to choose: a union when the shapes have no continuity, such as signed out versus signed in; status-plus-data when one screen loads, refreshes and paginates one collection
Distinguish empty from not-loaded: an empty list and a list that has not loaded yet must not render the same widget
Either way: value equality on every field or emits get swallowed, never mutate the list in place, and keep one-shot effects such as a String? snackbarMessage out of the state
Blocs provided above MaterialApp live for the whole app run. Signing out only changed the route, while every bloc kept the previous user's state, subscriptions and caches.
Why the state survives: BlocProvider closes what it created only when that provider leaves the tree, and navigating to the login route removes screens, not providers above the Navigator
Rebuilding is not enough: same widget types in the same positions means Flutter reuses the elements, and a reused BlocProvider keeps the bloc it already holds
**Key the signed-in subtree by user, above the Navigator**: build MultiBlocProvider(key: ValueKey(auth.userId), ...) in MaterialApp.builder from the auth state: a new id unmounts the old elements, so every scoped bloc is closed and recreated empty. Put it above the Navigator, otherwise pushed routes cannot find the blocs (go_router: same in a ShellRoute or builder)
What must stay app-wide handles sign-out explicitly: cancel repository subscriptions, clear caches, await HydratedBloc.storage.clear(), then emit the initial state
What still leaks: in-flight requests resolve into the new session, and repository singletons, cookie jars and the image cache live outside bloc