Bloc & Cubit #
The BLoC (Business Logic Component) library is a state management architectural pattern that strictly separates business logic from the UI layer using the power of streams. First introduced by the Google development team at Google I/O 2018, BLoC forces apps to have a highly predictable unidirectional data flow. In the flutter_bloc library ecosystem, you’re given two abstraction types that can be adapted to feature complexity: Cubit, based on direct method calls (function-to-state), and Bloc, which is event-driven for full traceability. This combination makes it a top choice for enterprise-scale projects and large team collaboration.
Installation #
To use BLoC in your project, add the following libraries to your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
# BLoC integration package for Flutter
flutter_bloc: ^8.1.6
# Used to simplify state object comparison
equatable: ^2.0.5
Cubit — The Simpler BLoC #
Cubit is a simplified version of Bloc that removes the need to define Event classes. Instead of dispatching events, you call regular function methods on the Cubit from the UI layer, then emit new state to subscribers using the internal emit() method.
flowchart TD
subgraph Alur_Cubit["Cubit (Direct Flow)"]
UI_C["UI (Button Pressed)"] -->|Call Function / Method| Cubit["Cubit (Business Logic)"]
Cubit -->|"Emit: emit(State)"| UI_C_Rebuild["UI Rebuild / Re-render"]
end
subgraph Alur_Bloc["BLoC (Event-Based Flow)"]
UI_B["UI (Button Pressed)"] -->|"Dispatch: add(Event)"| Bloc["BLoC (Event Handler & Stream)"]
Bloc -->|"Map Event to State: emit(State)"| UI_B_Rebuild["UI Rebuild / Re-render"]
end1. Defining State Objects #
It’s recommended to use the Equatable package on status classes so Flutter doesn’t rebuild the UI when the new status property values are the same as the old ones.
import 'package:equatable/equatable.dart';
abstract class CounterState extends Equatable {
const CounterState();
@override
List<Object?> get props => [];
}
class CounterInitial extends CounterState {}
class CounterUpdated extends CounterState {
final int value;
const CounterUpdated(this.value);
@override
List<Object?> get props => [value]; // Structured value comparison
}
2. Creating the Cubit Class #
Inside the Cubit, you directly define the status mutator functions.
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<CounterState> {
// Setting the initial state to CounterInitial
CounterCubit() : super(CounterInitial());
void increment() {
final currentValue = state is CounterUpdated
? (state as CounterUpdated).value
: 0;
emit(CounterUpdated(currentValue + 1));
}
void decrement() {
final currentValue = state is CounterUpdated
? (state as CounterUpdated).value
: 0;
emit(CounterUpdated(currentValue - 1));
}
void reset() => emit(CounterInitial());
}
BLoC — Event-Driven Architecture #
If your app feature has high complexity requiring concurrency control (like debouncing repeated requests), user activity history tracking (audit trail), or automatic analytics integration, using the full BLoC architecture is the best choice.
In BLoC, the UI is absolutely not allowed to call BLoC’s internal functions. The UI must emit event objects (Events) into the BLoC system using the add(Event) method.
1. Define Events #
Events act as a formal description of what interaction the app user is currently doing.
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class LoginSubmitted extends AuthEvent {
final String email;
final String password;
const LoginSubmitted({required this.email, required this.password});
@override
List<Object?> get props => [email, password];
}
class LogoutRequested extends AuthEvent {}
class SessionChecked extends AuthEvent {}
2. Define States #
Status represents the response that will be rendered by the interface.
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final String username;
const AuthAuthenticated(this.username);
@override
List<Object?> get props => [username];
}
class AuthUnauthenticated extends AuthState {}
class AuthError extends AuthState {
final String errorMessage;
const AuthError(this.errorMessage);
@override
List<Object?> get props => [errorMessage];
}
3. Implementing the BLoC Class #
Inside the BLoC, you register handler functions (event handlers) to map every event (Event) into new state emissions (State) through the on<Event> method.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
AuthBloc(this._repository) : super(AuthInitial()) {
// Registering event handlers
on<LoginSubmitted>(_onLoginSubmitted);
on<LogoutRequested>(_onLogoutRequested);
on<SessionChecked>(_onSessionChecked);
}
Future<void> _onLoginSubmitted(
LoginSubmitted event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final user = await _repository.login(event.email, event.password);
emit(AuthAuthenticated(user.name));
} catch (e) {
emit(AuthError(e.toString()));
}
}
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
await _repository.logout();
emit(AuthUnauthenticated());
}
Future<void> _onSessionChecked(
SessionChecked event,
Emitter<AuthState> emit,
) async {
final user = await _repository.fetchCurrentUser();
if (user != null) {
emit(AuthAuthenticated(user.name));
} else {
emit(AuthUnauthenticated());
}
}
}
BlocProvider — Dependency Injection into the Widget Tree #
To provide a BLoC or Cubit instance to your widget subtree, you use BlocProvider. This class automatically handles object lifecycle and calls dispose() when it’s no longer used.
// Registering one BLoC and immediately triggering the session check initialization
BlocProvider(
create: (BuildContext context) => AuthBloc(context.read<AuthRepository>())
..add(SessionChecked()),
child: const MainScreen(),
)
// Registering many BLoCs at once using MultiBlocProvider
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => AuthBloc(context.read<AuthRepository>())),
BlocProvider(create: (context) => CounterCubit()),
],
child: const MainScreen(),
)
BLoC Consumer Widgets in the UI Layer #
The flutter_bloc library provides a set of special widgets for interacting with data status inside the build method:
1. BlocBuilder (Reactive UI Re-rendering) #
BlocBuilder evaluates the current status and reconstructs the UI based on that value. You can optimize its performance using the buildWhen callback.
BlocBuilder<AuthBloc, AuthState>(
buildWhen: (previous, current) {
// Only rebuild if the status transition really changes data type
return previous != current;
},
builder: (BuildContext context, AuthState state) {
if (state is AuthLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is AuthAuthenticated) {
return Text('Welcome, ${state.username}!');
}
return const LoginFormWidget();
},
)
2. BlocListener (Handling One-Time Side Effects) #
BlocListener doesn’t physically rebuild the UI. It’s called exactly once for every status change.
- When to Use: To trigger modal dialogs, launch warning snackbars, or perform page route navigation.
BlocListener<AuthBloc, AuthState>(
listener: (BuildContext context, AuthState state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage)),
);
}
if (state is AuthAuthenticated) {
// Navigate to the home page
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
}
},
child: const LoginFormScreen(),
)
3. BlocConsumer (Combined Builder & Listener) #
When a widget needs to rebuild itself and trigger navigation side effects simultaneously, you use BlocConsumer to prevent widget stacking (nesting hell).
flowchart TD
BlocEmit["BLoC Emits New State"] --> BlocConsumer["BlocConsumer Widget"]
BlocConsumer --> CheckListener{"Does the state trigger\nlistenWhen?"}
BlocConsumer --> CheckBuilder{"Does the state trigger\nbuildWhen?"}
CheckListener -->|Yes| ListenerCallback["Run listener() Callback\n(Side Effects: Navigation, SnackBar)"]
CheckListener -->|No| IgnoreListener["Ignore Listener"]
CheckBuilder -->|Yes| BuilderCallback["Run builder() Callback\n(Re-render UI Display)"]
CheckBuilder -->|No| IgnoreBuilder["Skip Rebuild (Use Frame Cache)"]Here’s an example of using BlocConsumer:
BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage)),
);
}
},
builder: (context, state) {
if (state is AuthLoading) {
return const CircularProgressIndicator();
}
return LoginButton(
onPressed: () {
context.read<AuthBloc>().add(
const LoginSubmitted(email: '[email protected]', password: 'password123'),
);
},
);
},
)
4. BlocSelector (Partial Subscription Optimization) #
BlocSelector limits rebuilds to only when a specific property of the status class changes value, similar to context.select in Provider.
BlocSelector<AuthBloc, AuthState, bool>(
selector: (state) => state is AuthLoading,
builder: (context, isLoading) {
// Only rebuilt when the isLoading status changes (true/false)
return isLoading
? const CircularProgressIndicator()
: const Text('Submit Data');
},
)
State Design Patterns in BLoC #
When designing data status structures in BLoC architecture, there are two main writing patterns:
Pattern 1: Sealed Class Pattern #
Since Dart 3, it’s recommended to use the sealed class modifier to write separate status classes. This makes it easy to do exhaustive pattern matching that’s safe at compile time inside the UI.
sealed class DataState extends Equatable {
const DataState();
@override
List<Object?> get props => [];
}
class DataInitial extends DataState {}
class DataLoading extends DataState {}
class DataSuccess extends DataState {
final List<String> items;
const DataSuccess(this.items);
@override
List<Object?> get props => [items];
}
// In the UI, you can safely use switch expressions:
Widget build(BuildContext context) {
final state = context.watch<DataBloc>().state;
return Center(
child: switch (state) {
DataInitial() => const Text('Start loading data...'),
DataLoading() => const CircularProgressIndicator(),
DataSuccess(:final items) => Text('Items: ${items.length}'),
},
);
}
Pattern 2: Single State Class with copyWith #
This pattern uses a single status class with many optional properties and a copyWith method. This pattern is very popular for handling complex data entry forms.
class RegistrationFormState extends Equatable {
final String name;
final String email;
final bool isSubmitting;
final String? error;
const RegistrationFormState({
this.name = '',
this.email = '',
this.isSubmitting = false,
this.error,
});
RegistrationFormState copyWith({
String? name,
String? email,
bool? isSubmitting,
String? error,
}) {
return RegistrationFormState(
name: name ?? this.name,
email: email ?? this.email,
isSubmitting: isSubmitting ?? this.isSubmitting,
error: error,
);
}
@override
List<Object?> get props => [name, email, isSubmitting, error];
}
When to Choose Cubit vs BLoC? #
As a team architecture guide, here are the indicators for determining when to use Cubit and when to step up to BLoC:
- Use Cubit if:
- The status flow is very simple (e.g., dark theme toggle, panel collapse, or a local counter).
- The development team wants to reduce boilerplate code and needs high productivity.
- You don’t need concurrency control (like API request throttling/debouncing).
- Use BLoC if:
- The business logic flow is very complex and state-machine-like.
- You need centralized user event tracking features (audit trail).
- You need event transformer features like automatic cancellation (switchMap) or input throttling (throttle/debounce) on data submission.
Summary #
- Cubit manages state by accepting direct function instructions from the UI and emitting new state through the
emit()method.- BLoC enforces the use of Event classes as the event bridge between UI and business logic for complete traceability.
- Equatable: A mandatory component on status classes to ensure structured object comparison based on property values, avoiding visual jank from unnecessary rebuilds.
- BlocConsumer: An efficient combined widget for updating the UI while triggering dialog/navigation side effects at the same time.
- Sealed Class: Apply this feature (since Dart 3) to enforce compile-time validation of handling all status types.