Flutter BLoC Best Practices You're Probably Missing

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:
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.
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:
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:
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.
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:
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:
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: Passesdispose: (_, bloc) => bloc.close()to theInheritedProvider.valuemode: Nodisposecallback 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.
// ๐ฅ This won't auto-close the BLoC when disposed!
final provider = Provider<CounterBloc>(
create: (context) => CounterBloc(),
child: const CounterPage(),
);
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.
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:
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.
BlocProvider.value(
// ๐ฅ MEMORY LEAK: Who closes this? Nobody!
value: CounterBloc(),
child: const CounterPage(),
);
When using .value, you must manage the lifecycle yourself:
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.
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:
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: Theadd()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: UnlikeonChange()(which only has before/after state), transitions include the triggering event. This is whyBlocObservercan 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:
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.
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:
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.
// 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:
// 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.
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:
// 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());
}
});
}
}

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:
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:
BlocListenerchecks iflistenWhen(yourbuildWhen) returnstrue- 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.
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:
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.
// 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:
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:
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.
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:
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:
| 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.
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.
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:
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.
// 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:
// 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.
// Is this a class? A method? An event?
class FetchUsers {}
class UpdateProfile {}
class Loading {}
Consistent suffixes make the intent immediately clear:
// Immediately clear what these are
class FetchUsersEvent {}
class UpdateProfileEvent {}
class LoadingState {}
You can customize the pattern:
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:
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.





