BLoC (Business Logic Component) is one of the most battle-tested state management patterns in the Flutter ecosystem. It is explicit, testable, and scales to apps with hundreds of screens. This guide covers the patterns we use to ship production Flutter mobile apps with flutter_bloc.
Why BLoC
- Pure Dart business logic — no widget tree dependency
- Unidirectional data flow: events in, states out
- Easy to unit test without spinning up widgets
- First-class tooling:
bloc_test, devtools integration, hydrated state
Core Concepts
- Event: a thing that happened ("user tapped login")
- State: the resulting view of the world after handling events
- Bloc: maps events to states
- Cubit: simpler version, exposes methods instead of events
A Simple Counter Cubit
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}
// In the widget tree
BlocProvider(
create: (_) => CounterCubit(),
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
),
)
A Real-World Auth Bloc
sealed class AuthEvent {}
class AuthLoginRequested extends AuthEvent {
final String email, password;
AuthLoginRequested(this.email, this.password);
}
class AuthLogoutRequested extends AuthEvent {}
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
AuthAuthenticated(this.user);
}
class AuthFailure extends AuthState {
final String message;
AuthFailure(this.message);
}
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository repo;
AuthBloc(this.repo) : super(AuthInitial()) {
on<AuthLoginRequested>(_onLogin);
on<AuthLogoutRequested>(_onLogout);
}
Future<void> _onLogin(AuthLoginRequested e, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await repo.login(e.email, e.password);
emit(AuthAuthenticated(user));
} catch (err) {
emit(AuthFailure(err.toString()));
}
}
Future<void> _onLogout(AuthLogoutRequested e, Emitter<AuthState> emit) async {
await repo.logout();
emit(AuthInitial());
}
}
Wiring Into the UI
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
return switch (state) {
AuthLoading() => const CircularProgressIndicator(),
AuthAuthenticated(:final user) => HomeScreen(user: user),
_ => LoginScreen(),
};
},
)
BLoC vs Cubit: When to Use Which
- Cubit: simple flows, single source of truth, no need for event history
- Bloc: complex flows, debouncing, throttling, event tracking, undo/redo
Default to Cubit. Promote to Bloc when you actually need events.
Hydrated State
Persist state across app restarts with hydrated_bloc. Drop-in replacement for Bloc/Cubit that automatically writes state to disk:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(MyApp());
}
class ThemeCubit extends HydratedCubit<ThemeMode> {
ThemeCubit() : super(ThemeMode.system);
void toggle() => emit(state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark);
@override
ThemeMode? fromJson(Map<String, dynamic> json) =>
ThemeMode.values[json['index'] as int];
@override
Map<String, dynamic>? toJson(ThemeMode state) =>
{'index': state.index};
}
Composing Multiple BLoCs
For features that span multiple BLoCs, use BlocListener to bridge them or extract a coordinator that subscribes to streams:
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc(authRepo)),
BlocProvider(create: (_) => CartBloc(cartRepo)),
BlocProvider(create: (_) => OrdersBloc(ordersRepo)),
],
child: MaterialApp(...),
)
Testing
blocTest<AuthBloc, AuthState>(
'emits [Loading, Authenticated] on successful login',
build: () => AuthBloc(MockAuthRepository()..mockSuccess(User(id: '1'))),
act: (bloc) => bloc.add(AuthLoginRequested('a@b.com', 'pass')),
expect: () => [
isA<AuthLoading>(),
isA<AuthAuthenticated>(),
],
);
Performance Tips
- Use
buildWhenonBlocBuilderto skip unnecessary rebuilds - Keep state classes immutable; use
copyWith - Avoid storing widgets or controllers inside state
- Watch the equality contract — incorrect
==causes missed rebuilds or infinite loops
BLoC vs Riverpod vs Provider
Riverpod is more flexible and has less boilerplate. Provider is simpler for small apps. BLoC wins on large teams that benefit from explicit event/state contracts and a uniform pattern across hundreds of features.
Conclusion
BLoC is opinionated — and that opinion pays off in large Flutter apps. Use Cubit by default, promote to Bloc for complex flows, hydrate critical state, and lean on bloc_test for confidence. The boilerplate cost is real but it buys clarity, testability, and consistency that scale.