The Coordinated BLoC Pattern in Flutter — A Practical Guide
This post is based on real production code. Every mistake documented here was hit in the wild, debugged, and understood the hard way.
At first, coordinating multiple BLoCs from initState feels harmless.
Then the screen grows.
Now:
- some data depends on the authenticated user
- some sections support silent refresh
- some should show loading spinners
- some should skip fetching if cached
- pull-to-refresh has different behaviour than initial load
And suddenly your screen becomes the orchestration layer for half the app.
This article covers a pattern for extracting that orchestration into its own BLoC: the Coordination BLoC pattern.
More importantly, it covers the production mistakes this pattern introduces — especially the ones that fail silently.
What Problem Does This Solve?
Imagine a home screen that needs data from three different sources:
- meditation groups
- saadhna progress
- user favourites
Each has its own BLoC.
Each BLoC is independent.
But they all need to be triggered together after the user's profile loads.
You also want smart refresh behaviour:
- if data is fresh → silently refresh in background
- if data is missing → show loading state
- pull-to-refresh should force reload everything
Naively, you handle all of this inside initState:
@override
void initState() {
super.initState();
final profileState = context.read<ProfileBloc>().state;
if (profileState is ProfileLoaded) {
context.read<HomeMeditationGroupsBloc>()
.add(FetchHomeMeditationGroups());
context.read<SaadhnaBloc>()
.add(FetchAllSaadhnaGroups());
context.read<UserFavouritesBloc>()
.add(FetchUserFavouriteMeditations(profileState.id));
}
}
At first this works fine.
Then more conditions appear:
- freshness checks
- silent refreshes
- auth dependencies
- feature flags
- retry behaviour
- analytics
- cross-bloc coordination
And now your screen knows too much about how loading works.
The Coordination BLoC pattern extracts orchestration logic into its own BLoC.
The screen fires one high-level event.
The coordination bloc decides what happens.
Mental Model
The coordination bloc owns:
- when things load
- why they load
- which events should fire
The feature blocs still own:
- data
- business logic
- rendering state
The coordination bloc orchestrates.
It does not aggregate UI state.
Architecture
ProfileBloc
│
▼
HomeCoordinationBloc
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
HomeMeditationBloc SaadhnaBloc UserFavouritesBloc
The coordination bloc becomes the single orchestration point for screen-level loading behaviour.
The screen no longer knows:
- which blocs exist
- which events they need
- which dependencies they have
- which refresh strategy they use
Implementation
1. Events
Keep events coarse-grained and intent-based.
sealed class HomeCoordinatorEvent {}
class HomeDataFetch extends HomeCoordinatorEvent {}
class HomeDataRefresh extends HomeCoordinatorEvent {}
Event Intent
| Event | Meaning |
HomeDataFetch | Smart load with freshness checks |
HomeDataRefresh | Force reload everything |
2. States
The coordination bloc does not own feature data.
Its responsibility is orchestration, not aggregation.
sealed class HomeCoordinatorState {}
class HomeCoordinatorInitialState
extends HomeCoordinatorState {}
The UI still reads directly from feature blocs:
BlocBuilder<HomeMeditationGroupsBloc,
HomeMeditationGroupsState>(
builder: (context, state) {
// render UI
},
)
This separation is important.
The coordination bloc coordinates behaviour.
The feature blocs remain the source of truth.
3. The Coordination BLoC
The key idea
The screen fires one event:
HomeDataFetch()
The coordination bloc decides:
- which blocs should refresh
- whether data is stale
- whether loading should be silent
- whether user context is required
class HomeCoordinationBloc
extends Bloc<HomeCoordinatorEvent,
HomeCoordinatorState> {
final HomeMeditationGroupsBloc
homeMeditationGroupsBloc;
final SaadhnaBloc saadhnaBloc;
final UserFavouritesBloc
userFavouritesBloc;
final ProfileBloc profileBloc;
late StreamSubscription profileSubscription;
HomeCoordinationBloc({
required this.homeMeditationGroupsBloc,
required this.saadhnaBloc,
required this.userFavouritesBloc,
required this.profileBloc,
}) : super(HomeCoordinatorInitialState()) {
// Register handlers FIRST
on<HomeDataFetch>(_fetchData);
on<HomeDataRefresh>(_refetchData);
// Trigger immediately if already loaded
if (profileBloc.state is ProfileLoaded) {
add(HomeDataFetch());
}
// Otherwise wait for profile load
profileSubscription = profileBloc.stream
.where((state) => state is ProfileLoaded)
.take(1)
.listen((_) {
add(HomeDataFetch());
});
}
void _fetchData(
HomeDataFetch event,
Emitter emit,
) {
final userId =
(profileBloc.state as ProfileLoaded).id;
final isMeditationFresh =
homeMeditationGroupsBloc.state
is HomeMeditationGroupsFetched;
final isSaadhnaFresh =
saadhnaBloc.state
is SaadhnaGroupsFetched;
final isFavFresh =
userFavouritesBloc.state
is UserFavouritesFetched;
homeMeditationGroupsBloc.add(
isMeditationFresh
? SilentRefreshHomeMeditationGroups()
: FetchHomeMeditationGroups(),
);
saadhnaBloc.add(
isSaadhnaFresh
? RefetchAllSaadhnaGroups()
: FetchAllSaadhnaGroups(),
);
userFavouritesBloc.add(
isFavFresh
? RefetchUserFavouriteMeditations(userId)
: FetchUserFavouriteMeditations(userId),
);
}
void _refetchData(
HomeDataRefresh event,
Emitter emit,
) {
final userId =
(profileBloc.state as ProfileLoaded).id;
homeMeditationGroupsBloc.add(
RefreshHomeMeditationGroups(),
);
saadhnaBloc.add(
FetchAllSaadhnaGroups(),
);
userFavouritesBloc.add(
RefetchUserFavouriteMeditations(userId),
);
}
@override
Future<void> close() {
profileSubscription.cancel();
return super.close();
}
}
Dependency Injection (GetIt)
This is where most production bugs come from.
sl.registerLazySingleton(
() => HomeMeditationGroupsBloc(
getMeditationGroupsByType: sl(),
),
);
sl.registerLazySingleton(
() => SaadhnaBloc(
getAllGroups: sl(),
),
);
sl.registerLazySingleton(
() => UserFavouritesBloc(),
);
sl.registerLazySingleton(
() => HomeCoordinationBloc(
homeMeditationGroupsBloc: sl(),
saadhnaBloc: sl(),
userFavouritesBloc: sl(),
profileBloc: sl(),
),
);
BLoC Providers
BlocProvider<UserFavouritesBloc>(
create: (context) => sl<UserFavouritesBloc>(),
),
BlocProvider<HomeMeditationGroupsBloc>(
create: (context)
=> sl<HomeMeditationGroupsBloc>(),
),
BlocProvider<SaadhnaBloc>(
create: (context) => sl<SaadhnaBloc>(),
),
BlocProvider<HomeCoordinationBloc>(
create: (context)
=> sl<HomeCoordinationBloc>(),
),
The Screen
The screen now knows nothing about orchestration.
@override
Widget build(BuildContext context) {
return LiquidPullToRefresh(
onRefresh: () async {
context.read<HomeCoordinationBloc>()
.add(HomeDataRefresh());
},
child: ListView(
children: [
BlocBuilder<
HomeMeditationGroupsBloc,
HomeMeditationGroupsState>(
builder: (context, state) {
// render section
},
),
],
),
);
}
Mistakes to Not Make
Mistake 1: Registering Handlers After Calling add()
This causes a runtime Bad state error.
// WRONG
HomeCoordinationBloc(...)
: super(HomeCoordinatorInitialState()) {
if (profileBloc.state is ProfileLoaded) {
add(HomeDataFetch());
}
on<HomeDataFetch>(_fetchData);
}
// CORRECT
HomeCoordinationBloc(...)
: super(HomeCoordinatorInitialState()) {
on<HomeDataFetch>(_fetchData);
if (profileBloc.state is ProfileLoaded) {
add(HomeDataFetch());
}
}
The BLoC library checks for a handler immediately when add() is called.
Constructor order matters.
Mistake 2: Using registerFactory
This bug is especially nasty because everything appears to work — except the UI never updates.
// WRONG
sl.registerFactory(
() => HomeMeditationGroupsBloc(...),
);
What happens:
- Coordination bloc gets Instance A
- UI listens to Instance B
- Events fire correctly
- Data loads correctly
- UI never rebuilds
Two blocs.
Two states.
Zero visible errors.
// CORRECT
sl.registerLazySingleton(
() => HomeMeditationGroupsBloc(...),
);
Rule
Any bloc referenced by a coordination bloc should usually be a singleton.
Mistake 3: Forgetting to Cancel Stream Subscriptions
// WRONG
@override
Future<void> close() => super.close();
// CORRECT
@override
Future<void> close() {
profileSubscription.cancel();
return super.close();
}
Otherwise the subscription can attempt to call add() on a closed bloc.
Mistake 4: Forgetting .take(1)
Without .take(1):
profileBloc.stream
.where((state) => state is ProfileLoaded)
.listen((_) => add(HomeDataFetch()));
Every future ProfileLoaded state retriggers fetches.
That can create phantom refreshes after:
- token refreshes
- profile edits
- re-authentication
Usually you only want the first load:
profileBloc.stream
.where((state) => state is ProfileLoaded)
.take(1)
.listen((_) => add(HomeDataFetch()));
Mistake 5: Mixing GetIt and context.read
// Confusing and unnecessary
_homeCoordinationBloc =
GetIt.instance<HomeCoordinationBloc>();
Then later:
context.read<HomeCoordinationBloc>()
Pick one access pattern.
If the bloc comes from BlocProvider,
prefer context.read<X>().
Mistake 6: Leaving Half the Logic in the Screen
Once you introduce a coordination bloc, commit to it.
// Split ownership
context.read<HomeCoordinationBloc>()
.add(HomeDataFetch());
context.read<SaadhnaBloc>()
.add(FetchAllSaadhnaGroups());
Now orchestration lives in two places.
That is worse than either approach alone.
The Hidden Cost: Forced Singletons
This pattern introduces a real architectural tradeoff.
Your feature blocs now live for the entire app lifecycle.
That means:
- cached state survives navigation
- previous data persists
- memory usage increases
- state does not automatically reset
This is usually correct for:
- home feeds
- dashboards
- cached content
- long-lived tabs
This is usually dangerous for:
- forms
- temporary workflows
- checkout flows
- wizard-style screens
Do not accidentally turn screen-scoped state into app-scoped state.
When To Use This Pattern
Use it when:
- 3+ blocs must coordinate together
- another bloc's state gates loading
- loading logic contains real conditions
- orchestration is reused across screens
- refresh behaviour is non-trivial
When NOT To Use It
Do not use it when:
- you're only triggering 1-2 blocs
- coordination is just sequential
add() calls
- the screen logic is still simple
- feature blocs should remain screen-scoped
In those cases, a BlocListener is simpler and cleaner.
The Simpler Alternative
For smaller flows:
BlocListener<ProfileBloc, ProfileState>(
listenWhen: (prev, curr) =>
curr is ProfileLoaded &&
prev is! ProfileLoaded,
listener: (context, state) {
final userId =
(state as ProfileLoaded).id;
context.read<HomeMeditationGroupsBloc>()
.add(FetchHomeMeditationGroups());
context.read<SaadhnaBloc>()
.add(FetchAllSaadhnaGroups());
context.read<UserFavouritesBloc>()
.add(
FetchUserFavouriteMeditations(
userId,
),
);
},
child: YourScreen(),
)
This solution:
- is smaller
- avoids singletons
- avoids stream subscriptions
- avoids an extra bloc entirely
Start here first.
Graduate to coordination blocs only when the orchestration becomes genuinely complex.
Summary
| Coordination BLoC | BlocListener |
| Complexity | High | Low |
| Testability | High | Medium |
| Reusability | High | Low |
| Singleton Requirement | Usually Yes | No |
| Best For | Complex orchestration | Simple fan-out logic |
The Coordination BLoC pattern is a real solution for real complexity.
The mistake is introducing it too early.
Start simple.
Escalate only when the orchestration actually demands it.