
Reading source code is honestly one of the best ways to understand how things actually work. I've been building Flutter apps with the BLoC pattern for years, and I thought I had it figured out, until I opened the bloc repository and started digging through the code.
What I found was code that makes you go "oh, that's why." That StateError that crashes your app at 2 AM? It's literally one if statement. The "my UI isn't updating" bug that haunts every developer? One line of code explains the whole thing.
I realized that understanding these patterns at the source level is exactly what separates solid state management from fragile code. So I decided to walk you through the BLoC source code and show how BLoC lint rules connect directly to what's actually happening under the hood.
You can find the full source code examples for all the rules discussed in this article in our examples repository.
Let's get started!
The Foundation
Every Bloc and Cubit extends BlocBase, which implements the core lifecycle. Let's look at the emit() method, it's where all the magic (and bugs) happen:
From bloc/lib/src/bloc_base.dart
abstract class BlocBase<State>
implements StateStreamableSource<State>, Emittable<State>, ErrorSink {
BlocBase(this._state) {
_blocObserver.onCreate(this);
}
final _stateController = StreamController<State>.broadcast();
State _state;
bool _emitted = false;
@override
State get state => _state;
@override
Stream<State> get stream => _stateController.stream;
@override
bool get isClosed => _stateController.isClosed;
@protected
@visibleForTesting
@override
void emit(State state) {
try {
if (isClosed) {
throw StateError('Cannot emit new states after calling close');
}
if (state == _state && _emitted) return;
onChange(Change<State>(currentState: this.state, nextState: state));
_state = state;
_stateController.add(_state);
_emitted = true;
} catch (error, stackTrace) {
onError(error, stackTrace);
rethrow;
}
}
@mustCallSuper
@override
Future<void> close() async {
_blocObserver.onClose(this);
await _stateController.close();
}
}
Two lines in this code explain two of the most common BLoC bugs. Let me show you.
The isClosed Guard
if (isClosed) {
throw StateError('Cannot emit new states after calling close');
}
This is the source of "Cannot emit new states after calling close", one of the most common BLoC errors.
Here's the scenario: user opens a screen, triggers an API call, then navigates away immediately. The widget disposes, close() gets called, but the async operation is still running. When it finishes and tries to emit... crash.
This is exactly what check-is-not-closed-after-async-gap catches.
❌ Bad: Will crash if BLoC closes during the await
class SearchBlocBad extends Bloc<SearchEvent, SearchState> {
SearchBlocBad(this._repository) : super(SearchInitialImpl()) {
on<SearchQueryChanged>(_onQueryChanged);
}
final SearchRepository _repository;
Future<void> _onQueryChanged(
SearchQueryChanged event,
Emitter<SearchState> emit,
) async {
emit(SearchLoadingState());
final results = await _repository.search(event.query);
emit(SearchSuccessImpl(results));
}
}
The fix is simple, just check isClosed before emitting:
✅ Good: Always check isClosed after async gaps
Future<void> _onQueryChanged(
SearchQueryChanged event,
Emitter<SearchState> emit,
) async {
emit(SearchLoadingState());
final results = await _repository.search(event.query);
if (!isClosed) {
emit(SearchSuccessImpl(results));
}
}
The rule also supports custom event, listening methods through configuration:
analysis_options.yaml
dcm:
rules:
- check-is-not-closed-after-async-gap:
additional-methods:
- customOn
This single rule eliminates an entire category of production crashes. I can't stress enough how important it is.
The Equality Short-Circuit
Now look at the second critical line:
if (state == _state && _emitted) return;
This line silently skips emits when the state hasn't changed. The == operator uses your state's equality implementation. If you emit the same instance, identical(this, other) returns true and the UI never rebuilds.
This is the infamous "my UI isn't updating" bug, and it's what emit-new-bloc-state-instances catches.
❌ Bad: Emitting the same instance, UI won't update
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc() : super(UserState()) {
on<UpdateNameEvent>((event, emit) {
state.name = event.name;
emit(state);
});
}
}
The solution is to always create a new state instance:
✅ Good: Always emit a new instance
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc() : super(const UserState()) {
on<UpdateNameEvent>((event, emit) {
emit(state.copyWith(name: event.name));
});
}
}
@immutable
class UserState {
final String name;
const UserState({this.name = ''});
UserState copyWith({String? name}) {
return UserState(name: name ?? this.name);
}
}
This bug is a nightmare to debug because everything looks right. The event fires, the handler runs, emit gets called... but nothing happens on screen. The rule catches it instantly.
Read more about BLoC Library FAQ: State not updating
The Provider Lifecycle
The BlocProvider source code shows exactly why two common memory bugs happen:
From flutter_bloc/lib/src/bloc_provider.dart
class BlocProvider<T extends StateStreamableSource<Object?>>
extends SingleChildStatelessWidget {
const BlocProvider({
required T Function(BuildContext context) create,
Key? key,
this.child,
this.lazy = true,
}) : _create = create,
_value = null,
super(key: key, child: child);
const BlocProvider.value({
required T value,
Key? key,
this.child,
}) : _value = value,
_create = null,
lazy = true,
super(key: key, child: child);
final Widget? child;
final bool lazy;
final T Function(BuildContext context)? _create;
final T? _value;
@override
Widget buildWithChild(BuildContext context, Widget? child) {
assert(
child != null,
'$runtimeType used outside of MultiBlocProvider must specify a child',
);
final value = _value;
return value != null
? InheritedProvider<T>.value(
value: value,
startListening: _startListening,
lazy: lazy,
child: child,
)
: InheritedProvider<T>(
create: _create,
dispose: (_, bloc) => bloc.close(),
startListening: _startListening,
lazy: lazy,
child: child,
);
}
}
Look at the two code paths:
create: mode: Passes dispose: (_, bloc) => bloc.close() to the InheritedProvider
.value mode: No dispose callback at all
This is exactly why the provider lifecycle rules exist.

Using the Wrong Provider Type
Before we even get to create: vs .value, there's an even more fundamental mistake:
| Using a generic Provider when you should be using BlocProvider.
If your project uses both bloc and provider packages, it's easy to accidentally wrap a BLoC in a plain Provider, which bypasses the lifecycle management that BlocProvider provides.
This is what prefer-correct-bloc-provider catches.
❌ Bad: Using Provider instead of BlocProvider
final provider = Provider<CounterBloc>(
create: (context) => CounterBloc(),
child: const CounterPage(),
);
✅ Good: Use BlocProvider for BLoCs
final blocProvider = BlocProvider<CounterBloc>(
create: (context) => CounterBloc(),
child: const CounterPage(),
);
Passing Existing Instances to create:
BlocProvider(create: ...) takes ownership of the BLoC instance. It will call close() when the provider is disposed. But if you pass an existing instance to create:, you've created a lifecycle mismatch.
This is what avoid-existing-instances-in-bloc-provider catches.
❌ Bad: BlocProvider will close an instance it doesn't own
class MyApp extends StatelessWidget {
final counterBloc = CounterBloc();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => counterBloc,
child: const CounterPage(),
);
}
}
Instead, let the provider create and own the instance:
✅ Good: Let BlocProvider create and own the instance
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterBloc(),
child: const CounterPage(),
);
}
}
This causes double-close errors or worse use-after-close bugs that are super hard to track down. The BLoC seems fine sometimes and crashes randomly other times.
Instantiating in .value
This is the flip side. When you use .value, you're telling the provider: "I'm giving you an existing instance; don't touch its lifecycle." But if you instantiate a new BLoC directly in .value, nobody will close it.
This is what avoid-instantiating-in-bloc-value-provider catches.
❌ Bad: Memory leak, this BLoC will never be closed
BlocProvider.value(
value: CounterBloc(),
child: const CounterPage(),
);
When using .value, you must manage the lifecycle yourself:
✅ Good: Pass an existing instance that you manage
class ParentWidget extends StatefulWidget {
@override
State<ParentWidget> createState() => _ParentWidgetState();
}
class _ParentWidgetState extends State<ParentWidget> {
late final CounterBloc _counterBloc;
@override
void initState() {
super.initState();
_counterBloc = CounterBloc();
}
@override
void dispose() {
_counterBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _counterBloc,
child: const CounterPage(),
);
}
}
Silent memory leaks accumulate until your app becomes sluggish and eventually crashes. These leaks don't show up in testing but destroy user experience in production.
The Event System
The Bloc class builds on BlocBase by adding event-driven architecture. Let's look at how on<E>() works:
From bloc/lib/src/bloc.dart
abstract class Bloc<Event, State> extends BlocBase<State>
implements BlocEventSink<Event> {
Bloc(super.initialState);
final _eventController = StreamController<Event>.broadcast();
final _subscriptions = <StreamSubscription<dynamic>>[];
final _handlers = <_Handler>[];
final _emitters = <_Emitter<dynamic>>[];
@override
void add(Event event) {
assert(() {
final handlerExists = _handlers.any((handler) => handler.isType(event));
if (!handlerExists) {
final eventType = event.runtimeType;
throw StateError(
'''add($eventType) was called without a registered event handler.\n'''
'''Make sure to register a handler via on<$eventType>((event, emit) {...})''',
);
}
return true;
}());
try {
onEvent(event);
_eventController.add(event);
} catch (error, stackTrace) {
onError(error, stackTrace);
rethrow;
}
}
void on<E extends Event>(
EventHandler<E, State> handler, {
EventTransformer<E>? transformer,
}) {
assert(() {
final handlerExists = _handlers.any((handler) => handler.type == E);
if (handlerExists) {
throw StateError(
'on<$E> was called multiple times. '
'There should only be a single event handler per event type.',
);
}
_handlers.add(_Handler(isType: (dynamic e) => e is E, type: E));
return true;
}());
final subscription = (transformer ?? _eventTransformer)(
_eventController.stream.where((event) => event is E).cast<E>(),
(dynamic event) {
void onEmit(State state) {
if (isClosed) return;
if (this.state == state && _emitted) return;
onTransition(Transition(
currentState: this.state,
event: event as E,
nextState: state,
));
emit(state);
}
},
).listen(null);
_subscriptions.add(subscription);
}
}
Key takeaways from this code:
-
Events flow through a StreamController: The add() method doesn't directly call handlers, it pushes to a stream that handlers subscribe to. This is the unidirectional flow that public methods violate.
-
onTransition() captures the full picture: Unlike onChange() (which only has before/after state), transitions include the triggering event. This is why BlocObserver can provide complete tracing if you use events.
-
Guard clauses everywhere: Notice if (isClosed) return; appears multiple times. The library tries to protect you, but async gaps can still slip through.
The Change and Transition Types
Understanding these immutable records explains why we need immutable events and states:
From bloc/lib/src/change.dart and transition.dart
@immutable
class Change<State> {
const Change({required this.currentState, required this.nextState});
final State currentState;
final State nextState;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Change<State> &&
runtimeType == other.runtimeType &&
currentState == other.currentState &&
nextState == other.nextState;
}
@immutable
class Transition<Event, State> extends Change<State> {
const Transition({
required State currentState,
required this.event,
required State nextState,
}) : super(currentState: currentState, nextState: nextState);
final Event event;
}
If your events are mutable, a Transition captured by BlocObserver could have its event changed after being logged, creating weird bugs that are almost impossible to track down.
Public Methods Bypass Events
The entire point of the BLoC pattern is unidirectional data flow. Events go in, states come out. When you add public methods to a BLoC, you create a back door that bypasses the event system.
This is what avoid-bloc-public-methods catches.
❌ Bad: Public methods bypass the event system
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0);
void incrementDirectly() {
emit(state + 1);
}
@override
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
}
Keep all state mutations flowing through events:
✅ Good: Everything flows through events
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<IncrementPressed>((event, emit) => emit(state + 1));
}
void _logAnalytics() { }
@override
void onChange(Change<int> change) {
super.onChange(change);
print(change);
}
}
context.read<CounterBloc>().add(IncrementPressed());
When you use events, Bloc.on<E>() creates Transition objects that include the event. But Cubit.emit() (and direct emit() calls from public methods) only create Change objects, no event information. This is why BlocObserver.onTransition() gives you full traceability, but only if you actually use events.
Every public method is technical debt. Your future self (or teammates) will have to figure out which state changes come from events and which come from direct method calls.
Context and BLoC-to-BLoC
Two more architectural rules deserve attention.
BuildContext Doesn't Belong in BLoCs
When you pass BuildContext to a BLoC event or Cubit method, you're creating a vulnerable situation. The context is tied to a specific widget in the tree. If that widget is disposed while the BLoC is still processing, you get errors like "looking up a deactivated widget's ancestor."
Beyond crashes, passing context destroys testability. How do you unit test a BLoC that needs a real BuildContext?
This is what avoid-passing-build-context-to-blocs catches.
❌ Bad: Context coupling creates crashes and untestable code
bloc.add(LoadUserEvent(context));
class LoadUserEvent extends UserEvent {
final BuildContext context;
LoadUserEvent(this.context);
}
class SettingsCubit extends Cubit<SettingsState> {
void loadTheme(BuildContext context) async {
final theme = Theme.of(context);
}
}
Extract the data you need before passing it to the BLoC:
✅ Good: Pass data, not context
final userId = context.read<AuthBloc>().state.userId;
bloc.add(LoadUserEvent(userId));
class LoadUserEvent extends UserEvent {
final String userId;
LoadUserEvent(this.userId);
}
class SettingsCubit extends Cubit<SettingsState> {
final ThemeRepository _themeRepository;
SettingsCubit(this._themeRepository) : super(SettingsInitial());
void loadTheme() async {
final theme = await _themeRepository.getCurrentTheme();
emit(SettingsLoaded(theme));
}
}
BLoCs Should Not Know About Each Other
It's tempting to inject one BLoC into another when they need to coordinate. But this creates tight coupling and circular dependency risks. If BlocA depends on BlocB, and BlocB needs data from BlocA, you've got a maintenance nightmare.
This is what avoid-passing-bloc-to-bloc catches.
❌ Bad: Direct BLoC-to-BLoC coupling
class CartBloc extends Bloc<CartEvent, CartState> {
final AuthBloc authBloc;
late final StreamSubscription _authSubscription;
CartBloc(this.authBloc) : super(CartInitial()) {
_authSubscription = authBloc.stream.listen((authState) {
if (authState is Unauthenticated) {
add(ClearCartEvent());
}
});
}
@override
Future<void> close() {
_authSubscription.cancel();
return super.close();
}
}
There are two better approaches: coordinate in the widget layer, or use a shared stream:
✅ Good: Coordinate at the presentation layer or through repositories
class CartPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is Unauthenticated) {
context.read<CartBloc>().add(ClearCartEvent());
}
},
child: const CartView(),
);
}
}
class CartBloc extends Bloc<CartEvent, CartState> {
final CartRepository _cartRepository;
final Stream<AuthStatus> _authStatusStream;
CartBloc({
required CartRepository cartRepository,
required Stream<AuthStatus> authStatusStream,
}) : _cartRepository = cartRepository,
_authStatusStream = authStatusStream,
super(CartInitial()) {
_authStatusStream.listen((status) {
if (status == AuthStatus.unauthenticated) {
add(ClearCartEvent());
}
});
}
}

The "quick fix" of passing one BLoC to another always leads to maintenance headaches as the app grows.
The BlocBuilder Rebuild Logic
Let's look at how BlocBuilder decides when to rebuild:
From flutter_bloc/lib/src/bloc_builder.dart
typedef BlocBuilderCondition<S> = bool Function(S previous, S current);
class BlocBuilder<B extends StateStreamable<S>, S>
extends BlocBuilderBase<B, S> {
const BlocBuilder({
required this.builder,
Key? key,
B? bloc,
BlocBuilderCondition<S>? buildWhen,
}) : super(key: key, bloc: bloc, buildWhen: buildWhen);
final BlocWidgetBuilder<S> builder;
@override
Widget build(BuildContext context, S state) => builder(context, state);
}
class _BlocBuilderBaseState<B extends StateStreamable<S>, S>
extends State<BlocBuilderBase<B, S>> {
late B _bloc;
late S _state;
@override
void initState() {
super.initState();
_bloc = widget.bloc ?? context.read<B>();
_state = _bloc.state;
}
@override
Widget build(BuildContext context) {
if (widget.bloc == null) {
context.select<B, bool>((bloc) => identical(_bloc, bloc));
}
return BlocListener<B, S>(
bloc: _bloc,
listenWhen: widget.buildWhen,
listener: (context, state) => setState(() => _state = state),
child: widget.build(context, _state),
);
}
}
The key insight here: BlocBuilder delegates to BlocListener under the hood! The buildWhen callback is passed as listenWhen to the listener. When a new state arrives:
BlocListener checks if listenWhen (your buildWhen) returns true
- If true, the listener calls
setState(() => _state = state), triggering a rebuild
- If false, the listener doesn't fire, so no
setState, no rebuild
This is a powerful optimization, but if you leave buildWhen empty or forget to add it for expensive widgets, you're missing optimization opportunities.
This is what avoid-empty-build-when catches.
❌ Bad: BlocBuilder without buildWhen might rebuild too often
BlocBuilder<CounterBloc, int>(
builder: (context, state) {
return ExpensiveWidget(count: state);
},
);
Add an explicit buildWhen to control when rebuilds happen:
✅ Good: Explicit buildWhen for optimization
BlocBuilder<CounterBloc, int>(
buildWhen: (previous, current) {
return previous != current;
},
builder: (context, state) {
return ExpensiveWidget(count: state);
},
);
Dart 3 Type Safety
Dart 3 introduced sealed classes, and they're a game-changer for BLoC patterns. These rules leverage Dart 3's type system to catch bugs at compile time rather than runtime.
Sealed Events and States
Dart 3's sealed classes enable exhaustiveness checking. If your events and states are sealed, the compiler will warn you when you add a new event but forget to handle it, or when your switch statement doesn't cover all states.
This is what prefer-sealed-bloc-events and prefer-sealed-bloc-state enforce.
❌ Bad: No compile-time exhaustiveness checking
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
class ResetEvent extends CounterEvent {}
void handleEvent(CounterEvent event) {
if (event is IncrementEvent) {
} else if (event is DecrementEvent) {
}
}
With sealed classes, the compiler enforces exhaustive handling:
✅ Good: Sealed classes enable exhaustiveness checking
sealed class CounterEvent {}
final class IncrementEvent extends CounterEvent {}
final class DecrementEvent extends CounterEvent {}
final class ResetEvent extends CounterEvent {}
void handleEvent(CounterEvent event) {
switch (event) {
case IncrementEvent():
case DecrementEvent():
}
}
You can configure the naming pattern:
analysis_options.yaml
dcm:
rules:
- prefer-sealed-bloc-events:
name-pattern: Event$
- prefer-sealed-bloc-state:
name-pattern: State$
Immutable Events
If an event can be modified after it's created, you risk mutation-based side effects. An event handler might change a property, affecting how subsequent handlers (or replays) process the same event.
This is what prefer-immutable-bloc-events catches.
❌ Bad: Mutable events can be modified after dispatch
class UpdateUserEvent extends UserEvent {
String name;
UpdateUserEvent(this.name);
}
final event = UpdateUserEvent('Alice');
bloc.add(event);
event.name = 'Bob';
Make events immutable with final fields and const constructors:
✅ Good: Immutable events are predictable
@immutable
sealed class UserEvent {}
@immutable
final class UpdateUserEvent extends UserEvent {
final String name;
const UpdateUserEvent(this.name);
}
Design Patterns in BLoC
When you look at the BLoC source code, you can spot several design patterns at work:
| Pattern | Where It Appears | Purpose |
|---|
| Observer | BlocObserver, stream.listen() | React to state changes without tight coupling |
| Command | Event classes | Encapsulate actions as objects for replay, logging |
| Mediator | BlocProvider | Decouple BLoC creation from widget tree |
| Strategy | EventTransformer | Swap event processing strategies (debounce, throttle) |
| Immutable Value Object | Change, Transition, States | Ensure predictable, traceable state history |
Understanding these patterns explains why the BLoC architecture works and why breaking its contracts (public methods, mutable events, coupled BLoCs) causes problems.
Code Style Rules
These rules don't prevent bugs, but they make your codebase more consistent and easier to maintain.
When you need multiple BLoCs at the same level, nesting BlocProvider widgets creates an indentation nightmare. MultiBlocProvider is syntactic sugar that keeps things flat.
❌ Bad: Nesting hell
BlocProvider<AuthBloc>(
create: (context) => AuthBloc(),
child: BlocProvider<SettingsBloc>(
create: (context) => SettingsBloc(),
child: BlocProvider<ThemeBloc>(
create: (context) => ThemeBloc(),
child: BlocProvider<AnalyticsBloc>(
create: (context) => AnalyticsBloc(),
child: const MyApp(),
),
),
),
);
Use MultiBlocProvider to keep things flat:
✅ Good: Flat and readable
MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>(create: (context) => AuthBloc()),
BlocProvider<SettingsBloc>(create: (context) => SettingsBloc()),
BlocProvider<ThemeBloc>(create: (context) => ThemeBloc()),
BlocProvider<AnalyticsBloc>(create: (context) => AnalyticsBloc()),
],
child: const MyApp(),
);
Use context.read<T>() instead of BlocProvider.of<T>(context). The extension methods are shorter, more consistent, and make it harder to forget listen: true when you need watch semantics.
❌ Bad: Verbose and easy to forget listen parameter
final bloc = BlocProvider.of<CounterBloc>(context);
final bloc = BlocProvider.of<CounterBloc>(context, listen: true);
The extension methods are clearer and more concise:
✅ Good: Clear intent, shorter code
final bloc = context.read<CounterBloc>();
final bloc = context.watch<CounterBloc>();
When events are named FetchUsers instead of FetchUsersEvent, it becomes harder to distinguish events from methods, classes, or other constructs at a glance.
❌ Bad: Inconsistent naming
class FetchUsers {}
class UpdateProfile {}
class Loading {}
Consistent suffixes make the intent immediately clear:
✅ Good: Clear suffixes
class FetchUsersEvent {}
class UpdateProfileEvent {}
class LoadingState {}
You can customize the pattern:
analysis_options.yaml
dcm:
rules:
- prefer-bloc-event-suffix:
name-pattern: Event$
ignore-subclasses: true
- prefer-bloc-state-suffix:
name-pattern: State$
ignore-subclasses: true
Recommended Configuration
Here's a starting analysis_options.yaml configuration that enables all BLoC rules with sensible defaults:
analysis_options.yaml
dcm:
rules:
- check-is-not-closed-after-async-gap
- avoid-existing-instances-in-bloc-provider
- avoid-instantiating-in-bloc-value-provider
- avoid-passing-build-context-to-blocs
- avoid-bloc-public-methods
- avoid-bloc-public-fields
- emit-new-bloc-state-instances
- avoid-passing-bloc-to-bloc
- prefer-sealed-bloc-events:
name-pattern: Event$
- prefer-sealed-bloc-state:
name-pattern: State$
- prefer-immutable-bloc-events:
name-pattern: Event$
- prefer-immutable-bloc-state:
name-pattern: State$
- prefer-multi-bloc-provider
- prefer-bloc-extensions
- avoid-empty-build-when
- prefer-bloc-event-suffix:
name-pattern: Event$
ignore-subclasses: true
- prefer-bloc-state-suffix:
name-pattern: State$
ignore-subclasses: true
Conclusion
Reading source code is one of the best ways to level up as a developer. The next time you encounter a bug or wonder why a pattern exists, open the source and look for yourself. You might be surprised what you find.
Happy coding!