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.

  4. 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 (or buildWhen) low in the tree, and avoid emitting new state instances when nothing actually changed (pair with Equatable).


How helpful was this content?

Please sign in to rate this article.