BLoC Pattern

High PriorityAsked in ~70% of mid-level interviews

5 min read

State Management

UI ──(event)──►  BLoC ──(call)──►  Data layer (repository / API)
UI ◄──(state)── BLoC ◄──(data)── Data layer
LayerResponsibility
WidgetDispatch events; rebuild from states
BLoCMap events to states; orchestrate business logic
RepositoryFetch / 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

SituationPick
Large team / strict architecture / multi-feature appBLoC
Auditable event log, time-travel debuggingBLoC
Most other production apps with non-trivial logicCubit
Tiny screen-local statesetState
Reactive global state, no event ceremonyRiverpod

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

  1. 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).

  2. 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.

  3. Why pair sealed classes with BLoC? Because exhaustive switch over 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.


How helpful was this content?

Please sign in to rate this article.