Cubit: Simplified BLoC Without Events
Cubit: Simplified BLoC Without Events
In the previous lesson you saw how full BLoC separates intent (events) from reaction (state). Cubit is a lighter variant of the same pattern that removes the event layer entirely. Instead of dispatching events, you call public methods directly on the Cubit, which then emit new state. The result is less boilerplate while keeping all the benefits of a predictable, testable state machine.
Cubit is part of the flutter_bloc package (version 8+). You do not need a separate dependency — both Bloc and Cubit ship together.Cubit vs Full BLoC at a Glance
Understanding the difference helps you choose the right tool for each feature:
- BLoC — events are dispatched by the UI; the bloc maps each event to a state change. Best when you need an audit trail, event transformations, or
EventTransformerconcurrency control. - Cubit — the UI calls a named method directly; the cubit emits new state. Best for straightforward CRUD-style logic where event objects add ceremony without value.
- Both expose exactly the same
BlocBuilder/BlocListener/BlocProviderAPI in the widget tree, so migrating between them is painless.
EventTransformer (e.g., debounce or restartable), promote the Cubit to a full BLoC.Defining State Classes for a Cubit
Every Cubit is generic over its state type. That state can be anything — a primitive, an enum, or a rich immutable class. For non-trivial features, a dedicated state class is the recommended approach because it makes transitions explicit and easy to test.
There are two common patterns:
- Sealed class hierarchy — one abstract base plus concrete subclasses for each variant (
Loading,Loaded,Error). Dart 3 sealed classes enable exhaustive pattern-matching. - Single data class with status field — one class with a
statusenum field and nullable payload. Simpler but every property is always present even when unused.
Example 1 — Counter Cubit (primitive state)
import 'package:flutter_bloc/flutter_bloc.dart';
// State is just an int — no wrapper class needed
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0); // initial state = 0
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
The emit() method is the only way to push a new state. Calling emit with an equal value to the current state is a no-op — the stream does not fire and the UI does not rebuild.
Example 2 — Auth Cubit with sealed state hierarchy
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
// --- State definitions ---
sealed class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
final class AuthInitial extends AuthState {
const AuthInitial();
}
final class AuthLoading extends AuthState {
const AuthLoading();
}
final class AuthAuthenticated extends AuthState {
const AuthAuthenticated({required this.userId, required this.email});
final String userId;
final String email;
@override
List<Object?> get props => [userId, email];
}
final class AuthFailure extends AuthState {
const AuthFailure({required this.message});
final String message;
@override
List<Object?> get props => [message];
}
// --- Cubit ---
class AuthCubit extends Cubit<AuthState> {
AuthCubit({required this.authRepository}) : super(const AuthInitial());
final AuthRepository authRepository;
Future<void> login(String email, String password) async {
emit(const AuthLoading());
try {
final user = await authRepository.login(email, password);
emit(AuthAuthenticated(userId: user.id, email: user.email));
} catch (e) {
emit(AuthFailure(message: e.toString()));
}
}
void logout() => emit(const AuthInitial());
}
Connecting a Cubit to the Widget Tree
Providing a Cubit and consuming it in the UI uses the identical BlocProvider / BlocBuilder API you already know from full BLoC. The swap is invisible to the widgets.
- Wrap a subtree with
BlocProvider(create: (_) => CounterCubit())to make the cubit accessible. - Use
BlocBuilder<CounterCubit, int>to rebuild widgets when state changes. - Trigger state changes by calling
context.read<CounterCubit>().increment()— no event object to construct.
When to Choose Cubit Over Full BLoC
Use Cubit when:
- State transitions map one-to-one with method calls — no branching logic per event.
- The feature is self-contained and unlikely to need stream transformations.
- You want a fast feedback loop: less code to write and fewer files per feature.
- You are prototyping and may promote to full BLoC later.
Prefer BLoC when:
- You need to debounce, throttle, or cancel in-flight async work via
EventTransformer. - Multiple event types share the same state outcome and you want explicit audit logging.
- Your team enforces strict separation of intent from implementation for large codebases.
emit() after the Cubit has been closed. This typically happens when an async operation completes after the widget is disposed. Guard with if (!isClosed) emit(newState); or cancel the operation in close().Summary
Cubit removes the event layer from the BLoC pattern, letting you trigger state transitions by calling methods directly. It ships in the same flutter_bloc package and integrates with the identical widget API. Define state as a primitive for simple counters or flags, and as a sealed class hierarchy with Equatable for features that have loading/error/success variants. Use Cubit for clean, concise logic and graduate to full BLoC only when you need advanced stream concurrency control.