Cubit Fundamentals
The Simplest Step Up From setState — Master This Pattern First
Open interactive version (quiz + challenge)Real-world analogy
A Cubit is like a vending machine. You press a button (call a method), the machine processes your request, and dispenses a product (emits a new state). You don't need to know how the vending machine works internally — you just know the inputs (methods) and outputs (states). And crucially, the machine always tells you exactly what's inside it right now (current state).
What is it?
Cubit is a lightweight state management class from the BLoC library. It simplifies BLoC by replacing Events with direct method calls. Methods call emit() to produce new immutable states. The UI subscribes via BlocBuilder. Equatable ensures state comparison works correctly, preventing unnecessary rebuilds.
Real-world relevance
In a school management platform, the attendance marking feature uses an AttendanceCubit with methods: loadStudents(), markPresent(studentId), markAbsent(studentId), submitAttendance(). States: AttendanceInitial, AttendanceLoading, AttendanceLoaded(students), AttendanceSubmitting, AttendanceSubmitted. Simple enough for Cubit — no complex event transformations needed.
Key points
- What Is a Cubit? — Cubit is a simplified BLoC without the Event class. Instead of defining events, you call methods directly on the Cubit. Methods call emit() to produce new states. The UI listens via BlocBuilder or BlocListener. Cubit is perfect for features with simple, direct operations where explicit event modeling adds unnecessary boilerplate.
- Cubit vs BLoC: When to Choose — Cubit: simpler features, direct method calls, less boilerplate, easier to read for small features. BLoC: complex features where you want explicit event modeling for auditability (every action is traceable in BlocObserver), event transformation (debouncing, throttling), or complex side effects. A BLoC can be converted to Cubit and back easily.
- emit() — The Core Operation — emit(newState) pushes a new state to all listeners. States are compared with == before emitting — if the new state equals the current state, emit is a no-op (no rebuild). This is why Equatable matters: without it, two identical state objects won't be equal (object identity), causing unnecessary rebuilds.
- Equatable for State Comparison — Equatable overrides == and hashCode based on props. class UserLoaded extends Equatable { final User user; const UserLoaded(this.user); @override List get props => [user]; }. Now UserLoaded(user1) == UserLoaded(user1) returns true — Cubit won't re-emit and won't rebuild the UI.
- Immutable States Pattern — Every state class should be immutable: final fields, const constructor where possible. Never mutate state directly — always emit a new state object. For states with many fields, use copyWith(). This enables time-travel debugging, state history, and trivial test assertions.
- State Design — Sealed Classes — Use sealed classes for exhaustive state handling. sealed class UserState {} + subclasses (Initial, Loading, Loaded, Error). In the UI, switch (state) { case UserInitial() => ..., case UserLoading() => ..., } gives compile-time exhaustiveness — compiler warns if you miss a state.
- BlocProvider and BlocBuilder — BlocProvider creates and provides the Cubit. BlocBuilder rebuilds when state changes. BlocBuilder has buildWhen: if you return false, the widget skips this rebuild. Use buildWhen to prevent rebuilds for state changes your widget doesn't care about.
- Cubit Lifecycle: onCreate and onClose — override onCreate() to run logic when Cubit is first created (load initial data). override onClose() to clean up resources (cancel subscriptions). BlocObserver.onCreate/onClose give global visibility into all Cubit/BLoC lifecycles — useful for debugging and analytics.
- Testing Cubits — Pure and Simple — Cubit testing: instantiate directly (no widget needed), call methods, check state list with blocTest or manual assertion. blocTest('description', build: () => MyCubit(), act: (c) => c.someMethod(), expect: () => [StateA(), StateB()]). No widget pumping, no async complications.
- Common Cubit Patterns — Counter cubit: increment/decrement methods. Auth cubit: login/logout/checkSession methods. Form cubit: updateField, submit, reset methods. Theme cubit: toggle method. Pagination cubit: loadNext, refresh methods. For each: start simple with Cubit, upgrade to BLoC only when you need event transformation or explicit event logging.
Code example
// Cubit Fundamentals
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
// ============================================================
// 1. STATE DESIGN — Sealed + Equatable
// ============================================================
sealed class AttendanceState extends Equatable {
const AttendanceState();
@override
List<Object?> get props => []; // Default: no props to compare
}
class AttendanceInitial extends AttendanceState {
const AttendanceInitial();
}
class AttendanceLoading extends AttendanceState {
const AttendanceLoading();
}
class AttendanceLoaded extends AttendanceState {
final List<Student> students;
final Map<String, bool> attendance; // studentId → isPresent
const AttendanceLoaded({
required this.students,
required this.attendance,
});
@override
List<Object> get props => [students, attendance];
// copyWith: create new state with modified fields
AttendanceLoaded copyWith({
List<Student>? students,
Map<String, bool>? attendance,
}) {
return AttendanceLoaded(
students: students ?? this.students,
attendance: attendance ?? this.attendance,
);
}
}
class AttendanceSubmitting extends AttendanceState {
const AttendanceSubmitting();
}
class AttendanceSubmitted extends AttendanceState {
final int presentCount;
const AttendanceSubmitted(this.presentCount);
@override
List<Object> get props => [presentCount];
}
class AttendanceError extends AttendanceState {
final String message;
const AttendanceError(this.message);
@override
List<Object> get props => [message];
}
// ============================================================
// 2. THE CUBIT
// ============================================================
class AttendanceCubit extends Cubit<AttendanceState> {
final AttendanceRepository _repository;
// Initial state passed to super constructor
AttendanceCubit({required AttendanceRepository repository})
: _repository = repository,
super(const AttendanceInitial());
// Called when Cubit is created — load initial data
@override
Future<void> onCreate(BlocBase bloc) async {
super.onCreate(bloc);
await loadStudents();
}
// Direct method — no Event class needed
Future<void> loadStudents() async {
emit(const AttendanceLoading()); // Immediate feedback
try {
final students = await _repository.getStudents();
final initialAttendance = {
for (final s in students) s.id: false
};
emit(AttendanceLoaded(
students: students,
attendance: initialAttendance,
));
} catch (e) {
emit(AttendanceError('Failed to load students: $e'));
}
}
// Update one student's attendance — immutable state update
void markAttendance(String studentId, bool isPresent) {
final current = state;
if (current is! AttendanceLoaded) return;
// Create new map — don't mutate the existing one
final newAttendance = Map<String, bool>.from(current.attendance)
..[studentId] = isPresent;
// emit only if changed — Equatable handles the comparison
emit(current.copyWith(attendance: newAttendance));
}
Future<void> submitAttendance() async {
final current = state;
if (current is! AttendanceLoaded) return;
emit(const AttendanceSubmitting());
try {
await _repository.submitAttendance(current.attendance);
final presentCount =
current.attendance.values.where((v) => v).length;
emit(AttendanceSubmitted(presentCount));
} catch (e) {
// On error, restore the loaded state so user can retry
emit(AttendanceError('Submission failed: $e'));
}
}
// Resources cleanup
@override
Future<void> close() async {
// Cancel any subscriptions, timers here
return super.close();
}
}
// ============================================================
// 3. UI — BlocBuilder
// ============================================================
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AttendanceScreen extends StatelessWidget {
const AttendanceScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AttendanceCubit(
repository: context.read<AttendanceRepository>(),
),
child: const _AttendanceView(),
);
}
}
class _AttendanceView extends StatelessWidget {
const _AttendanceView();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Attendance')),
body: BlocBuilder<AttendanceCubit, AttendanceState>(
// buildWhen: skip rebuild if we don't need it
// buildWhen: (prev, curr) => curr is! AttendanceSubmitting,
builder: (context, state) {
return switch (state) {
AttendanceInitial() => const Center(child: Text('Loading...')),
AttendanceLoading() => const Center(child: CircularProgressIndicator()),
AttendanceLoaded(:final students, :final attendance) =>
_StudentList(students: students, attendance: attendance),
AttendanceSubmitting() => const Center(child: CircularProgressIndicator()),
AttendanceSubmitted(:final presentCount) =>
Center(child: Text('Submitted! $presentCount present')),
AttendanceError(:final message) =>
Center(child: Text('Error: $message')),
};
},
),
floatingActionButton: BlocBuilder<AttendanceCubit, AttendanceState>(
buildWhen: (prev, curr) =>
prev is AttendanceLoaded || curr is AttendanceLoaded,
builder: (context, state) {
if (state is! AttendanceLoaded) return const SizedBox.shrink();
return FloatingActionButton.extended(
onPressed: () =>
context.read<AttendanceCubit>().submitAttendance(),
label: const Text('Submit'),
);
},
),
);
}
}
class _StudentList extends StatelessWidget {
final List<Student> students;
final Map<String, bool> attendance;
const _StudentList({required this.students, required this.attendance});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: students.length,
itemBuilder: (context, index) {
final student = students[index];
final isPresent = attendance[student.id] ?? false;
return CheckboxListTile(
title: Text(student.name),
value: isPresent,
onChanged: (val) {
context.read<AttendanceCubit>().markAttendance(
student.id,
val ?? false,
);
},
);
},
);
}
}
// Placeholder types
class Student {
final String id;
final String name;
Student({required this.id, required this.name});
}
abstract class AttendanceRepository {
Future<List<Student>> getStudents();
Future<void> submitAttendance(Map<String, bool> attendance);
}Line-by-line walkthrough
- 1. Sealed class AttendanceState — compiler ensures exhaustive switch handling
- 2. Each state extends Equatable and defines props — equality based on data, not object identity
- 3. AttendanceLoaded.copyWith() — creates new state with modified fields without mutating
- 4. AttendanceCubit extends Cubit with super(initialState)
- 5. loadStudents() emits Loading, then Loaded or Error — standard async pattern
- 6. markAttendance() guards with 'is!' check — safe state narrowing
- 7. New Map from existing — immutable update pattern, never mutate the map in-place
- 8. emit() skips if new state equals current (via Equatable) — prevents useless rebuilds
- 9. BlocProvider creates Cubit scoped to the screen
- 10. BlocBuilder switch exhaustively handles all states — sealed class enforces this
- 11. buildWhen on FAB BlocBuilder — only rebuilds when AttendanceLoaded changes
Spot the bug
class ThemeCubit extends Cubit<ThemeState> {
ThemeCubit() : super(ThemeState(isDark: false));
void toggleTheme() {
state.isDark = !state.isDark;
emit(state);
}
}
class ThemeState {
bool isDark;
ThemeState({required this.isDark});
}Need a hint?
ThemeState is mutable. What happens when you toggle the theme?
Show answer
Two bugs: (1) state.isDark is mutated directly — this mutates the current state object BEFORE emitting. (2) emit(state) emits the SAME object reference — Cubit compares old and new state by reference/equality, finds them identical, and skips the rebuild. Fix: make ThemeState immutable (final isDark), extend Equatable, and emit a new instance: emit(ThemeState(isDark: !state.isDark)).
Explain like I'm 5
A Cubit is like a scoreboard. You call methods on it — 'add 1 point to the red team' — and it updates its score (emits a new state). Everyone watching the scoreboard (BlocBuilder) gets the update automatically. Equatable makes sure the scoreboard only flashes 'updated!' when the score actually changes — not just because a new scoreboard object was created with the same numbers.
Fun fact
The name 'Cubit' is a portmanteau of 'Cube' and 'Bit' — a nod to quantum computing concepts (qubits). Felix Angelov, the creator of the BLoC library, introduced Cubit in 2020 as a lighter alternative to BLoC for simpler use cases. Despite the quantum naming inspiration, Cubit is decidedly classical in its behavior.
Hands-on challenge
Build a SearchCubit for a student search feature: state has a query string and a filtered list of students. searchStudents(query) method filters a hardcoded list. Write the states, Cubit, and a test using blocTest that verifies: initial state is empty, after searching 'Ali' the state contains matching students.
More resources
- Cubit Documentation (bloclibrary.dev)
- BLoC Library (bloclibrary.dev)
- Equatable Package (pub.dev)
- flutter_bloc Package (pub.dev)