How BLoC Actually Works Under the Hood — and How to Stop It From Rebuilding Your Whole App
Master the exact internal event-to-state pipeline and solve the most common architectural bug in Flutter apps: cascading, unnecessary rebuilds.
You've seen the pattern a hundred times: a user taps a filter chip on a product-listing screen, the `Bloc` emits a new state, and the UI updates. But what happens if updating that filter chip secretly rebuilds your entire product grid, the cart badge in your app bar, and the bottom navigation bar?
Most developers can wire up a `BlocProvider` and a `BlocBuilder`. But the moment a senior interviewer asks, "Walk me through exactly what happens internally between `bloc.add(Event)` and the widget rebuilding," things get quiet. Understanding that internal pipeline is the key to solving the most common performance issue in BLoC-based apps: the monolithic state rebuild.
In this post—a deep-dive companion to our earlier BLoC vs Riverpod 3.0 architectural breakdown—we're opening the black box. We'll trace the exact execution path of a BLoC event, and then use that knowledge to surgically prevent unnecessary UI rebuilds.
Contents
Part 1: How It Works
Part 2: Preventing Rebuild Problems
01. The Exact Internal Mechanism
To control a framework, you have to know how it routes its data. When you call bloc.add(FilterTapped()) on our theoretical product-listing screen, here is the exact sequence of events that executes under the hood:
- The Event Controller: Calling
bloc.add(event)pushes your event into an internalStreamController<Event>. It doesn't execute immediately; it joins a queue. - The Event Loop: The Bloc's internal machinery listens to this event stream, picks up the event, and routes it to the specific
on<EventType>handler you registered in your constructor. - The State Stream (Crucial): Inside the handler, when you call
emit(newState), the Bloc pushes that new state into its internal state stream. This is a vital architectural detail:Bloc<Event, State>extendsBlocBase<State>, which itself extendsStream<State>. A Bloc literally is a stream of states. - The Subscription: Widgets like
BlocBuilder,BlocListener, andBlocConsumerestablish aStreamSubscriptionto that state stream during theirinitState. - The Gatekeeper Check: When a new state arrives, the listening widget does not rebuild immediately. First, it checks its
buildWhen(orlistenWhen) callback, as well as distinct state equality. - The Rebuild: If those checks pass, the widget calls
setState()internally, triggering itsbuild()method with the new data.
02. Why Streams? Determinism & Queues
A common interview follow-up: "Why use streams internally? Why not just call the handler synchronously when `add()` is called?"
The answer is determinism and ordering. By routing events through a stream, BLoC guarantees that events are processed sequentially, one at a time, in the exact order they were added. This prevents race conditions where a fast network response from a later event overwrites a slow network response from an earlier event.
This strict sequential processing is exactly what makes tools like bloc_test (which we covered in the Testing Strategy post) able to verify exact event-to-state sequences reliably. If you need to break this default sequential behavior—say, to debounce rapid search-input events, or drop events while one is currently processing—you use Event Transformers (like `restartable` or `droppable` from the `bloc_concurrency` package). Know that these exist as the lever to pull when the default queue isn't what you want.
03. The Gatekeepers: buildWhen & listenWhen
By default, when a Bloc emits a new state, every BlocBuilder attached to it rebuilds. But BlocBuilder provides an optional callback parameter called buildWhen (and BlocListener provides listenWhen).
These callbacks give you the previous state and the current state, and expect you to return a boolean. If you return true, the widget rebuilds. If false, it ignores the emission entirely. This is your primary defense against cascading rebuilds. We will look at exactly how to implement this in Part 2.
04. State Equality & Equatable
Before `buildWhen` is even evaluated, BLoC performs an internal check: is the new state distinct from the old state? Under the hood, BLoC uses Dart's == operator. If currentState == previousState is true, the emission is swallowed and ignored.
This is why overriding == and hashCode on your State classes—usually by extending the Equatable package—is non-negotiable in production apps. If you don't, two states that are logically identical (same cart count, same filter) but are different object instances in memory will trigger an unnecessary rebuild due to reference equality.
class ProductState extends Equatable {
final int cartCount;
final String selectedFilter;
const ProductState({required this.cartCount, required this.selectedFilter});
// Equatable uses this list to override == and hashCode automatically
@override
List<Object> get props => [cartCount, selectedFilter];
}
05. The Monolith State Pattern (The Bug)
Now that we know the pipeline, let's look at the bug. You've built a product listing screen. To keep things "simple," you created a single ProductBloc and a monolithic ProductState that holds everything: the list of products, the currently selected category filter, and the number of items in the user's cart.
The problem? Any change to any one field emits a new state. When the user adds an item to their cart, you emit a new ProductState with cartCount: 2. Because the state changed, every widget listening to ProductBloc rebuilds. Your cart badge updates (good!), but your heavy 200-item grid and your filter chips also completely rebuild (bad!).
Updating the Cart: The entire screen flashes/rebuilds because it shares one state object.
06. Fix 1: Scoping with buildWhen
The fastest fix for a monolithic state is explicitly scoping your UI rebuilds using the buildWhen gatekeeper we discussed in Part 1. You tell the BlocBuilder wrapping your Cart Badge to only care about changes to the cartCount field.
// BAD: Rebuilds every time ANY field in ProductState changes
BlocBuilder<ProductBloc, ProductState>(
builder: (context, state) => CartBadge(count: state.cartCount),
)
// GOOD: Only rebuilds when cartCount specifically changes
BlocBuilder<ProductBloc, ProductState>(
buildWhen: (previous, current) => previous.cartCount != current.cartCount,
builder: (context, state) => CartBadge(count: state.cartCount),
)
07. Fix 2: Splitting Oversized Blocs
While buildWhen works, it's a band-aid over a structural issue. If you find yourself writing complex buildWhen conditions across your entire app, your Bloc is doing too much. The architectural fix is to split independent concerns into separate Blocs/Cubits.
Instead of one massive ProductBloc, you create a CartCubit (managing only cart state) and a ProductListBloc (managing the grid and filters). The trade-off is clear: it requires more boilerplate and setup, but it eliminates the coupling entirely. The cart badge listens to CartCubit. The grid listens to ProductListBloc. They physically cannot cause each other to rebuild, resulting in a cleaner, naturally performant architecture.
08. Fix 3: Using context.select
Sometimes you have a deeply nested widget that just needs one primitive value—like the cart count—and wrapping it in a full BlocBuilder with a buildWhen feels overly verbose. The flutter_bloc package (via its dependency on provider) gives you context.select.
context.select subscribes the widget to a specific, derived value from the state. The widget will only rebuild when that specific derived value changes, acting as an implicit buildWhen.
class CompactCartBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
// The widget subscribes ONLY to the cartCount integer.
// It ignores all other changes in ProductState automatically.
final cartCount = context.select<ProductBloc, int>(
(bloc) => bloc.state.cartCount,
);
return Text('$cartCount items');
}
}
buildWhen
Surgical control over rebuilds on existing large blocs. Requires remembering to implement the check on every builder.
Bloc Splitting
Structural separation of concerns. Inherently prevents cross-talk rebuilds without needing manual checks.
context.select
Concise, reactive reading of a single primitive derived value without the visual noise of a Builder tree.
09. Fixing True Infinite Loops (Circular Dependencies)
Cascading rebuilds slow down your app, but a true infinite emission loop will crash it. This is a distinct, rarer bug pattern that occurs when a reactive side-effect feeds back into the Bloc that triggered it.
The pattern usually looks like this: your ProductBloc emits a "Cart Syncing" state. A BlocListener reacts to that state by showing a snackbar, but also erroneously dispatches a SyncComplete() event back to the same ProductBloc. The Bloc processes SyncComplete, emits a new state, the listener fires again, dispatches again, and the loop is sealed.
The fix requires auditing your listeners. Break the cycle by adding strict guard conditions (e.g., using listenWhen to only react once per specific state transition, or checking if the sync is already complete before dispatching). Better yet, restructure your architecture so listeners handle UI routing/snackbars exclusively, while inter-bloc communication happens at the logic layer (using StreamSubscriptions between Blocs) rather than bouncing through the UI.
π§ Interviewer's Follow-Up
Q: "Is Bloc actually a Stream under the hood?"
Pointer: Yes. State explicitly that BlocBase<State> extends Stream<State>, which is why widgets use StreamSubscription internally to listen to state emissions.
Q: "Why does my BlocBuilder rebuild even when I emit the exact same data?"
Pointer: You haven't overridden the == operator (via Equatable or manually). BLoC uses reference equality by default, so two different object instances with the same data are considered distinct states.
Senior-Level Sample Answer
If asked, "How do you prevent unnecessary rebuilds in a BLoC-based app?", here is how you structure a 45-second, airtight response:
"There isn't a single silver bullet; it's a layered approach. First, at the foundation, I ensure all State classes use Equatable so that logically identical state emissions are swallowed by Bloc's internal distinct check. Second, structurally, I avoid 'monolith' state objects. If a screen has a Cart and a Product Grid, I split those into a CartCubit and a ProductBloc so updating one physically cannot rebuild the other.
For cases where a shared state object is unavoidable, I scope the UI listeners. I use buildWhen on BlocBuilder to strictly compare previous.field != current.field. Or, if a leaf widget just needs a single primitive—like an integer cart count—I bypass the Builder entirely and use context.select to bind directly to that specific property."
✅ Quick Self-Check
Without looking at the code above, what are the two parameters passed to a buildWhen callback, and what type of value must it return to trigger a rebuild?
Official Resources
- Bloc Architecture & Core Concepts
- BlocBuilder & buildWhen API Documentation
- bloc_concurrency (Event Transformers)
Next in Series: Deep Dive: Advanced Routing with GoRouter (Pairs well with our BLoC vs Riverpod 3.0 architectural breakdown).
Written by Flutter Solution · Covering the Flutter & Dart ecosystem from Mumbai, India · fluttersolution.com

Comments
Post a Comment