Lesson 17 of 77 intermediate

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

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. 1. Sealed class AttendanceState — compiler ensures exhaustive switch handling
  2. 2. Each state extends Equatable and defines props — equality based on data, not object identity
  3. 3. AttendanceLoaded.copyWith() — creates new state with modified fields without mutating
  4. 4. AttendanceCubit extends Cubit with super(initialState)
  5. 5. loadStudents() emits Loading, then Loaded or Error — standard async pattern
  6. 6. markAttendance() guards with 'is!' check — safe state narrowing
  7. 7. New Map from existing — immutable update pattern, never mutate the map in-place
  8. 8. emit() skips if new state equals current (via Equatable) — prevents useless rebuilds
  9. 9. BlocProvider creates Cubit scoped to the screen
  10. 10. BlocBuilder switch exhaustively handles all states — sealed class enforces this
  11. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery