No error message. No crash. No red screen. The app just ignores you.
You changed the data. You called emit. You verified the event fired. You added print statements and confirmed the new state was constructed with the right values. And the UI sits there, unchanged, like nothing happened.
This is the most frustrating category of bug in Flutter — the kind with no error to Google. The app doesn't fail. It just silently does the wrong thing. And in BLoC-based architectures, the most common cause is a broken equality check that tells BLoC the state hasn't changed — when it has.
How BLoC Decides Whether to Rebuild
BLoC has a simple rule: if the new state equals the previous state, don't emit it. This is an optimization. If a BlocBuilder receives the same state twice, there's no reason to rebuild the widget tree. The framework skips the emission entirely.
The check happens inside emit:
// Simplified from the bloc package source:
void emit(State state) {
if (state == _state) return; // ← this line
_state = state;
_controller.add(state);
}That == comparison is where everything goes right or wrong. It relies entirely on how your state class implements equality.
Equatable: What It Does and Why You Use It
Dart objects use reference equality by default. Two different instances of the same class with the same fields are not equal:
class SearchState {
final String query;
final List<Lead> results;
SearchState({required this.query, required this.results});
}
final a = SearchState(query: 'flutter', results: leads);
final b = SearchState(query: 'flutter', results: leads);
print(a == b); // false — different instancesThis means BLoC would emit every state, even identical ones. Your UI rebuilds when nothing changed. That's wasteful but not broken — you'd never notice unless you profile.
Equatable solves this by overriding == to compare specific fields you define in a props list:
class SearchState extends Equatable {
final String query;
final List<Lead> results;
const SearchState({required this.query, required this.results});
@override
List<Object?> get props => [query, results];
}
final a = SearchState(query: 'flutter', results: leads);
final b = SearchState(query: 'flutter', results: leads);
print(a == b); // true — same query, same results list referenceNow BLoC can skip duplicate emissions. Efficient. Clean. Until you forget to update props.
The Trap: Adding a Field, Forgetting `props`
Here's where it breaks. Your SearchState works fine with query and results. Then you add a loading flag:
class SearchState extends Equatable {
final String query;
final List<Lead> results;
final bool isLoading; // new field
const SearchState({
required this.query,
required this.results,
this.isLoading = false,
});
@override
List<Object?> get props => [query, results]; // ← forgot isLoading
}Now watch what happens:
// In your BLoC:
void _onSearch(SearchEvent event, Emitter<SearchState> emit) async {
emit(SearchState(query: event.query, results: state.results, isLoading: true));
final results = await searchUseCase(event.query);
emit(SearchState(query: event.query, results: results, isLoading: false));
}The first emit sets isLoading: true. But props doesn't include isLoading. If query and results haven't changed yet, Equatable says the states are equal. BLoC swallows the emission. Your loading spinner never appears.
The second emit updates results, which is in props, so it goes through. The UI jumps directly from idle to showing results — no loading state in between.
No error. No warning. The code looks correct. You just never see the loading indicator, and you have no idea why.
The Fix
@override
List<Object?> get props => [query, results, isLoading];One field added to one list. That's it. But finding this without knowing where to look can cost an hour of print-statement debugging.
The Other Direction: Mutating Instead of Replacing
There's a mirror-image version of this bug that's equally silent. Instead of forgetting a field in props, you mutate an existing one:
void _onLeadAdded(LeadAdded event, Emitter<SearchState> emit) {
state.results.add(event.lead); // mutating the existing list
emit(SearchState(
query: state.query,
results: state.results, // same list reference
isLoading: false,
));
}state.results is the same List object. You added an item to it, but the reference didn't change. Equatable compares the reference (via == on the list), sees the same object, and says "equal." BLoC swallows the emission.
The fix:
void _onLeadAdded(LeadAdded event, Emitter<SearchState> emit) {
final updatedResults = [...state.results, event.lead]; // new list
emit(SearchState(
query: state.query,
results: updatedResults,
isLoading: false,
));
}A new list with the same contents plus the new item. Different reference. Equatable sees a change. BLoC emits. The UI rebuilds.
This is immutability in practice — not as a theoretical ideal, but as the thing that makes your state management actually work.
Why This Is a Clean Architecture Problem
In clean architecture, your state class sits at the boundary between the presentation layer and the rest of the system. It's the contract between your BLoC and your UI. When that contract is broken — when the state says "I haven't changed" while carrying different data — the UI trusts it and does nothing.
The use case returned the right data. The repository fetched correctly. The BLoC event handler ran. Everything below the surface worked perfectly. The failure happens at the very last mile, in the state class's equality definition, which is technically presentation-layer code.
This makes it hard to debug because you instinctively look deeper. You check the API response. You verify the repository mapping. You add logs to the use case. Everything looks correct, because everything is correct — except the one class whose entire job is to carry data from the BLoC to the widget.
Debugging Checklist
When your BLoC emits but the UI doesn't rebuild:
- Check `props` — does it include every field? This is the cause 80% of the time.
- Check for mutation — are you modifying a list or map in place instead of creating a new one? Use
[...list]or{...map}to create copies. - Check `buildWhen` — if your
BlocBuilderhas abuildWhencallback, it might be filtering out the emission you care about. - Check the widget tree — is the
BlocBuilderactually above the widget that needs to rebuild? ABlocBuilderat the wrong level of the tree won't help. - Temporarily remove Equatable — if the UI suddenly works without it, you've confirmed the equality check is the problem. Now find which field is missing from
props.
Key Insights
Equatable is a sharp tool. It solves a real problem — preventing unnecessary rebuilds — but it requires you to maintain props every time you touch the state class. One forgotten field, and BLoC silently drops emissions. The convenience of value equality comes with the responsibility of keeping the equality definition complete.
Immutability isn't optional with BLoC. Mutating a list inside a state object and re-emitting it will always fail with Equatable, because the reference hasn't changed. Every state emission must carry new object references for any field that changed. This is the price of the == optimization — your state must be truly new, not a modified version of the old one.
No error is worse than a wrong error. A crash tells you something broke. A silent non-emission tells you nothing. If you're choosing between "too many rebuilds" (no Equatable) and "risk missing emissions" (with Equatable), err on the side of rebuilding too much until your state classes are stable. You can optimize later. You can't debug what you can't see.
Consider `Freezed` for critical states. The Freezed package generates immutable classes with correct ==, hashCode, and copyWith automatically. You never write props by hand, so you never forget a field. The tradeoff is code generation overhead — but for state classes that change often, it removes an entire category of bugs.