By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
Cubit is a BLoC without event classes. Same package, same BlocBuilder, same testing infra — you just call methods (emit) instead of dispatching events. Use Cubit for state that changes through simple, internal logic (theme, onboarding step, a toggle). Use full BLoC for flows where user intent and external triggers must be traceable (checkout, auth, sync).
In our 50+ module codebase we use both: Cubit for lightweight feature state, BLoC for anything with a multi-step lifecycle or external inputs. The line is not aesthetic — it is whether you would ever need to ask "what events led to this state?" in a bug report.
| Feature | BLoC | Cubit |
|---|---|---|
| Event classes | Required | None — methods instead |
| API surface | on<Event> handlers | Methods calling emit |
| Boilerplate | Higher (events + states + bloc) | Lower (states + cubit) |
| Audit trail | Events logged via BlocObserver | State emissions only |
| Testing | blocTest with event sequences | blocTest with method calls |
| Event transformers | Yes (bloc_concurrency) | No (not event-based) |
| Same package? | Yes (bloc) | Yes (bloc) |
| Migration effort | — | Trivial (Cubit ↔ BLoC) |
| Best for | Multi-step, external-input flows | Simple, internal-logic state |
Cubit is part of the bloc package. It extends the same base, emits the same state objects, and works with BlocBuilder, BlocListener, and BlocProvider. The only difference: instead of registering on<Event> handlers, you expose public methods that call emit directly. There are no event classes.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
That is the entire API. If you have ever felt BLoC's event files were overkill for a settings screen, Cubit exists for you.
The event class is not ceremony for its own sake — it is an audit trail. Consider a checkout flow: SubmitCheckoutPressed, CouponApplied, PaymentMethodSelected, RetryAfterFailure. When a user reports "the order went through twice," you look at the BlocObserver log and see the exact event sequence with timestamps. With Cubit, the same mutations are method calls; the log shows state emissions but not the semantic trigger.
For a sync engine talking to PowerSync, events like SyncStarted, ConflictDetected, ConflictResolvedManually are the vocabulary of the system. Collapsing those into method calls would lose meaning.
Both are testable with the same bloc_test package:
// Cubit — act by calling methods
cubitTest<CounterCubit>(
'increments',
build: () => CounterCubit(),
act: (cubit) => cubit.increment(),
expect: () => [1],
);
You lose the ability to replay an event stream in a test, because there are no events. For most Cubit use cases that is fine — you are testing state outputs, not event choreography.
The convention we landed on after two years at scale:
This keeps the 80% of feature state lean (Cubit) and reserves the event-class overhead for the 20% where it pays.
sealed class ArticleEvent {}
class ArticleFetchRequested extends ArticleEvent {
final String id; const ArticleFetchRequested(this.id);
}
sealed class ArticleState {}
class ArticleInitial extends ArticleState {}
class ArticleLoading extends ArticleState {}
class ArticleLoaded extends ArticleState { final Article a; const ArticleLoaded(this.a); }
class ArticleBloc extends Bloc<ArticleEvent, ArticleState> {
ArticleBloc(this.repo) : super(ArticleInitial()) {
on<ArticleFetchRequested>(_onFetch);
}
final ArticleRepo repo;
Future<void> _onFetch(ArticleFetchRequested e, Emitter<ArticleState> emit) async {
emit(ArticleLoading());
final a = await repo.fetch(e.id);
emit(ArticleLoaded(a));
}
}
sealed class ArticleState {}
class ArticleInitial extends ArticleState {}
class ArticleLoading extends ArticleState {}
class ArticleLoaded extends ArticleState { final Article a; const ArticleLoaded(this.a); }
class ArticleCubit extends Cubit<ArticleState> {
ArticleCubit(this.repo) : super(ArticleInitial());
final ArticleRepo repo;
Future<void> fetch(String id) async {
emit(ArticleLoading());
final a = await repo.fetch(id);
emit(ArticleLoaded(a));
}
}
The Cubit is ~40% less code. For a single fetch that is the right call. If that flow later grows retries, conflict handling, and a manual refresh event, graduate it to a BLoC — the refactor is mechanical because the state classes stay identical.
Choose BLoC when: the flow has multiple triggers (user action + push notification + sync), you need bloc_concurrency for debouncing/dedup, or debugging requires an event timeline. The event class is documentation that the codebase cannot drift from.
Choose Cubit when: the state changes through simple methods, there is one obvious trigger, and the "why did this happen?" question is trivially answered by reading the method. Start with Cubit; promote to BLoC when complexity arrives — they share the same package so the migration is painless.
Yes — literally. Cubit is a base class in the bloc package. It omits event classes and lets you call emit from methods. Everything else (providers, builders, testing) is identical, so you can migrate between them with minimal effort.
Yes. The state classes and UI layer (BlocBuilder/BlocProvider) stay exactly the same. You add event classes and convert methods to on<Event> handlers. The refactor is mechanical and low-risk.
No — event transformers operate on the event stream, and Cubit has no events. If you need debouncing or dedup on triggers, either implement it inside the method (e.g. a Timer) or graduate to a full BLoC with bloc_concurrency.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work