Advanced State Management (Bloc & Riverpod)

Cubit: Simplified BLoC Without Events

15 min Lesson 3 of 14

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.

Note: 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 EventTransformer concurrency 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 / BlocProvider API in the widget tree, so migrating between them is painless.
Tip: A good rule of thumb — if you find yourself creating an event class with a single field and a single handler, replace it with a Cubit method. If you later need 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 status enum 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.
Warning: Never call 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.