Skip to main content

Flutter BLoC Best Practices You're Probably Missing

ยท 21 min read
Majid HajianDeveloper Advocate

Cover

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.

info

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;


State get state => _state;


Stream<State> get stream => _stateController.stream;


bool get isClosed => _stateController.isClosed; // ๐Ÿ‘ˆ Delegates to stream controller




void emit(State state) {
try {
if (isClosed) {
throw StateError('Cannot emit new states after calling close');
}
if (state == _state && _emitted) return; // ๐Ÿ‘ˆ Same-instance check!
onChange(Change<State>(currentState: this.state, nextState: state));
_state = state;
_stateController.add(_state);
_emitted = true;
} catch (error, stackTrace) {
onError(error, stackTrace);
rethrow;
}
}



Future<void> close() async {
_blocObserver.onClose(this);
await _stateController.close(); // ๐Ÿ‘ˆ Closing the controller marks isClosed as true
}
}

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);
// ๐Ÿ’ฅ CRASH: If user navigated away during the search, this emit throws
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);
// โœ… Safe: Check before emitting
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) {
// ๐Ÿ’ฅ This modifies the existing state object
state.name = event.name;

// ๐Ÿ’ฅ Same instance, same reference, UI ignores this emit!
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) {
// โœ… New instance with updated values
emit(state.copyWith(name: event.name));
});
}
}


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 {

// Constructor for create: mode
const BlocProvider({
required T Function(BuildContext context) create,
Key? key,
this.child,
this.lazy = true,
}) : _create = create,
_value = null,
super(key: key, child: child);

// Named constructor for .value mode
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;


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,
// โŒ NO dispose callback for .value!
child: child,
)
: InheritedProvider<T>(
create: _create,
dispose: (_, bloc) => bloc.close(), // โœ… Auto-close for create:
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.

BLoC Provider LifecycleBLoC Provider Lifecycle

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
// ๐Ÿ’ฅ This won't auto-close the BLoC when disposed!
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 {
// ๐Ÿšจ This BLoC is created outside the provider
final counterBloc = CounterBloc();


Widget build(BuildContext context) {
return BlocProvider(
// ๐Ÿ’ฅ BlocProvider will close this when disposed,
// but you might try to use it elsewhere!
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 {

Widget build(BuildContext context) {
return BlocProvider(
// โœ… Provider creates it, provider owns it, provider closes it
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(
// ๐Ÿ’ฅ MEMORY LEAK: Who closes this? Nobody!
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 {

State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
late final CounterBloc _counterBloc;


void initState() {
super.initState();
_counterBloc = CounterBloc();
}


void dispose() {
_counterBloc.close(); // โœ… You manage the lifecycle
super.dispose();
}


Widget build(BuildContext context) {
return BlocProvider.value(
value: _counterBloc, // โœ… Existing instance
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.

info

We also have written a comprehensive blog post about memory leaks and how it happens in Flutter in general. If you are interested to deep dive, read Let's talk about memory leaks in Dart and Flutter.

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>>[];


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,
}) {
// Prevent duplicate handlers for the same event type
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);
}
// ... handler invocation with Emitter
},
).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

class Change<State> {
const Change({required this.currentState, required this.nextState});

final State currentState;
final State nextState;


bool operator ==(Object other) =>
identical(this, other) ||
other is Change<State> &&
runtimeType == other.runtimeType &&
currentState == other.currentState &&
nextState == other.nextState;
}


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; // ๐Ÿ‘ˆ The event is captured in the transition
}

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);

// ๐Ÿšจ This bypasses events entirely!
// BlocObserver won't see this change.
// You can't replay this action.
// State history becomes incomplete.
void incrementDirectly() {
emit(state + 1);
}


void onChange(Change<int> change) {
super.onChange(change);
print(change); // Won't log incrementDirectly() calls properly
}
}

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));
}

// Keep implementation details private
void _logAnalytics() { /* ... */ }


void onChange(Change<int> change) {
super.onChange(change);
print(change); // โœ… All state changes are tracked
}
}

// Usage in widget:
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
// In your widget:
bloc.add(LoadUserEvent(context)); // ๐Ÿšจ Passing context to event

// In your event:
class LoadUserEvent extends UserEvent {
final BuildContext context; // ๐Ÿ’ฅ This will cause problems
LoadUserEvent(this.context);
}

// In your Cubit:
class SettingsCubit extends Cubit<SettingsState> {
// ๐Ÿ’ฅ What happens when this context is no longer mounted?
void loadTheme(BuildContext context) async {
final theme = Theme.of(context);
// ... use theme
}
}

Extract the data you need before passing it to the BLoC:

โœ… Good: Pass data, not context
// In your widget:
final userId = context.read<AuthBloc>().state.userId;
bloc.add(LoadUserEvent(userId)); // โœ… Pass the data you need

// In your event:
class LoadUserEvent extends UserEvent {
final String userId; // โœ… Just the data
LoadUserEvent(this.userId);
}

// In your Cubit:
class SettingsCubit extends Cubit<SettingsState> {
final ThemeRepository _themeRepository;

SettingsCubit(this._themeRepository) : super(SettingsInitial());

void loadTheme() async {
// โœ… Get theme data from repository, not context
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; // ๐Ÿšจ Direct dependency
late final StreamSubscription _authSubscription;

CartBloc(this.authBloc) : super(CartInitial()) {
// ๐Ÿšจ Tight coupling: CartBloc knows too much about AuthBloc
_authSubscription = authBloc.stream.listen((authState) {
if (authState is Unauthenticated) {
add(ClearCartEvent());
}
});
}


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
// Option 1: Coordinate in the widget layer
class CartPage extends StatelessWidget {

Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is Unauthenticated) {
// โœ… Widget coordinates between BLoCs
context.read<CartBloc>().add(ClearCartEvent());
}
},
child: const CartView(),
);
}
}

// Option 2: Use a shared repository/stream
class CartBloc extends Bloc<CartEvent, CartState> {
final CartRepository _cartRepository;
final Stream<AuthStatus> _authStatusStream; // โœ… Stream, not BLoC

CartBloc({
required CartRepository cartRepository,
required Stream<AuthStatus> authStatusStream,
}) : _cartRepository = cartRepository,
_authStatusStream = authStatusStream,
super(CartInitial()) {
_authStatusStream.listen((status) {
if (status == AuthStatus.unauthenticated) {
add(ClearCartEvent());
}
});
}
}
BLoC Coordination PatternsBLoC Coordination Patterns

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;


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;


void initState() {
super.initState();
_bloc = widget.bloc ?? context.read<B>();
_state = _bloc.state;
}


Widget build(BuildContext context) {
if (widget.bloc == null) {
// Trigger a rebuild if the bloc reference has changed.
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:

  1. BlocListener checks if listenWhen (your buildWhen) returns true
  2. If true, the listener calls setState(() => _state = state), triggering a rebuild
  3. 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>(
// ๐Ÿšจ No buildWhen means this rebuilds on EVERY state change
// If the builder is expensive, this could cause jank
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) {
// โœ… Only rebuild when the count actually changes
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
// Without sealed, nothing stops you from forgetting a case
abstract class CounterEvent {}

class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
class ResetEvent extends CounterEvent {} // Added later...

// In a widget somewhere:
void handleEvent(CounterEvent event) {
if (event is IncrementEvent) {
// handle
} else if (event is DecrementEvent) {
// handle
}
// ๐Ÿ’ฅ Oops! ResetEvent is silently ignored. No warning.
}

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 {}

// Now this is a compile error:
void handleEvent(CounterEvent event) {
switch (event) {
case IncrementEvent():
// handle
case DecrementEvent():
// handle
// โŒ Compile error: The type 'CounterEvent' is not exhaustively matched
// by the switch cases since it doesn't match 'ResetEvent()'.
}
}

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; // ๐Ÿšจ Mutable field

UpdateUserEvent(this.name);
}

// Somewhere in your code:
final event = UpdateUserEvent('Alice');
bloc.add(event);

// Later, accidentally or intentionally:
event.name = 'Bob'; // ๐Ÿ’ฅ Mutated after dispatch!
// If event handlers cache or compare events, this causes chaos.

Make events immutable with final fields and const constructors:

โœ… Good: Immutable events are predictable

sealed class UserEvent {}


final class UpdateUserEvent extends UserEvent {
final String name; // โœ… Final field

const UpdateUserEvent(this.name);
}

Design Patterns in BLoCโ€‹

When you look at the BLoC source code, you can spot several design patterns at work:

PatternWhere It AppearsPurpose
ObserverBlocObserver, stream.listen()React to state changes without tight coupling
CommandEvent classesEncapsulate actions as objects for replay, logging
MediatorBlocProviderDecouple BLoC creation from widget tree
StrategyEventTransformerSwap event processing strategies (debounce, throttle)
Immutable Value ObjectChange, Transition, StatesEnsure 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.

prefer-multi-bloc-providerโ€‹

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(), // 4 levels deep!
),
),
),
);

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(),
);

prefer-bloc-extensionsโ€‹

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
// Have to remember listen: false is default (read behavior)
final bloc = BlocProvider.of<CounterBloc>(context);

// Easy to forget listen: true when you need reactivity
final bloc = BlocProvider.of<CounterBloc>(context, listen: true);

The extension methods are clearer and more concise:

โœ… Good: Clear intent, shorter code
// Read once (doesn't rebuild on state changes)
final bloc = context.read<CounterBloc>();

// Watch (rebuilds when state changes)
final bloc = context.watch<CounterBloc>();

prefer-bloc-event-suffix & prefer-bloc-state-suffixโ€‹

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
// Is this a class? A method? An event?
class FetchUsers {}
class UpdateProfile {}
class Loading {}

Consistent suffixes make the intent immediately clear:

โœ… Good: Clear suffixes
// Immediately clear what these are
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

Here's a starting analysis_options.yaml configuration that enables all BLoC rules with sensible defaults:

analysis_options.yaml
dcm:
rules:
# Crash & leak prevention
- 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

# Architectural integrity
- avoid-bloc-public-methods
- avoid-bloc-public-fields
- emit-new-bloc-state-instances
- avoid-passing-bloc-to-bloc

# Modern type safety (Dart 3+)
- 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$

# Code style
- 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!

Enjoying this article?

Subscribe to get our latest articles and product updates by email.