BLoC Pattern
5 min read
State Management
UI ──(event)──► BLoC ──(call)──► Data layer (repository / API)
UI ◄──(state)── BLoC ◄──(data)── Data layer
| Layer | Responsibility |
|---|---|
| Widget | Dispatch events; rebuild from states |
| BLoC | Map events to states; orchestrate business logic |
| Repository | Fetch / cache / persist data |
The direction is one-way. The BLoC owns logic, the widget owns rendering, the repository owns I/O. Side effects (navigation, snackbars) belong in BlocListener, not in build.
Code in action — auth flow with sealed states
// Events
sealed class AuthEvent {}
class LoginRequested extends AuthEvent {
final String email, password;
LoginRequested(this.email, this.password);
}
class LogoutRequested extends AuthEvent {}
// States
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState { final User user; AuthSuccess(this.user); }
class AuthFailure extends AuthState { final String message; AuthFailure(this.message); }
// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc(this._repo) : super(AuthInitial()) {
on<LoginRequested>(_onLogin);
on<LogoutRequested>(_onLogout);
}
final AuthRepository _repo;
Future<void> _onLogin(LoginRequested e, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await _repo.login(e.email, e.password);
emit(AuthSuccess(user));
} catch (err) {
emit(AuthFailure('$err'));
}
}
Future<void> _onLogout(LogoutRequested e, Emitter<AuthState> emit) async {
await _repo.logout();
emit(AuthInitial());
}
}
// UI consumes via BlocConsumer — listen + build in one
BlocConsumer<AuthBloc, AuthState>(
listener: (ctx, state) {
if (state is AuthSuccess) Navigator.pushReplacementNamed(ctx, '/home');
if (state is AuthFailure) ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(state.message)),
);
},
builder: (ctx, state) => switch (state) {
AuthLoading() => const Center(child: CircularProgressIndicator()),
_ => const LoginForm(),
},
);
Cubit — when events are overkill
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
context.read<CounterCubit>().increment();
BlocBuilder<CounterCubit, int>(builder: (_, count) => Text('$count'));
Cubit drops the event layer — you call methods directly. Use it for simple state; reach for full BLoC when you need an explicit audit trail of dispatched events (analytics, replay, debugging).
When to reach for BLoC
| Situation | Pick |
|---|---|
| Large team / strict architecture / multi-feature app | BLoC |
| Auditable event log, time-travel debugging | BLoC |
| Most other production apps with non-trivial logic | Cubit |
| Tiny screen-local state | setState |
| Reactive global state, no event ceremony | Riverpod |
Common mistakes to avoid
// ❌ Doing navigation / snackbars inside builder
builder: (ctx, state) {
if (state is AuthSuccess) Navigator.push(...); // 💥 fires every rebuild
return ...;
}
// ✅ Use BlocListener / BlocConsumer's listener: for side effects
// ❌ One mega-Bloc for the whole app
class AppBloc extends Bloc<AppEvent, AppState> { ... 200 events ... }
// ✅ One Bloc per feature (Auth, Cart, Catalog) with clear boundaries
// ❌ Plain class events without value equality
class LoginRequested {
final String email, password;
LoginRequested(this.email, this.password);
}
// → two events with the same fields aren't ==; debugging is hard
// ✅ Use Equatable or freezed for events and states
// ❌ Emitting AuthSuccess(user) without sealed states + exhaustive switch
// You'll forget to handle a new state when added → silent UI bugs
// ✅ sealed class + switch without default
// ❌ Bloc-to-Bloc communication via direct references
class CartBloc { final AuthBloc auth; } // tight coupling
// ✅ Listen to streams (BlocListener bridging two Blocs) or share a repository
Interview follow-ups
-
How do you handle BLoC-to-BLoC communication? Three options, ordered by preference: (1) share a repository — both Blocs depend on the same source of truth; (2)
BlocListener<A, AState>(listener: (ctx, a) => ctx.read<B>().add(...))at the widget layer to bridge events; (3) inject one Bloc into another via constructor (tighter coupling — use sparingly). -
When would you use Cubit vs full BLoC? Cubit: simple state, fewer files, methods called directly. BLoC: explicit event stream you can log/replay, more disciplined for large teams, fits well with analytics or undo/redo features. Default to Cubit; promote to Bloc when you actually need events.
-
Why pair sealed classes with BLoC? Because exhaustive
switchover a sealed state means the compiler tells you everywhere that needs updating when you add a new state. Without it, "I forgot to handle Loading on this screen" becomes a runtime UI bug instead of a compile error. -
What's the performance cost of BLoC's stream-based design? Tiny in practice — Dart streams are efficient and BLoC adds a thin wrapper. The bigger perf lever is rebuild scope: use
BlocBuilder(orbuildWhen) low in the tree, and avoid emitting new state instances when nothing actually changed (pair withEquatable).
How helpful was this content?
Please sign in to rate this article.