Flutter Bloc and Cubit Tutorial: Events, States and Testing

Bloc (Business Logic Component) is a state management library built around a strict, event-driven pattern: instead of calling methods directly on your state object, the UI dispatches events, and the Bloc responds by emitting new states. Cubit is a simplified version of the same library that drops the event layer for cases where it’s more overhead than it’s worth.

Setup

dependencies:
  flutter_bloc: ^8.1.0

Cubit: The Simpler Starting Point

A Cubit exposes methods that directly emit new state — no separate event classes:

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}
BlocProvider(
  create: (context) => CounterCubit(),
  child: BlocBuilder<CounterCubit, int>(
    builder: (context, count) {
      return ElevatedButton(
        onPressed: () => context.read<CounterCubit>().increment(),
        child: Text('$count'),
      );
    },
  ),
)

emit() is Cubit’s equivalent of Provider’s notifyListeners() or Riverpod’s state assignment — it updates the state and triggers a rebuild of any BlocBuilder watching it.

Bloc: Adding an Event Layer

A full Bloc separates the “what happened” (an event) from the “what changed” (a state), with an explicit mapping between them:

sealed class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
  }
}
context.read<CounterBloc>().add(Increment());

The UI dispatches an Increment event via add() rather than calling a method directly. This extra layer is what makes Bloc’s logic easy to test in isolation and easy to trace — every state change has a corresponding event you can log or replay — at the cost of writing more classes for the same behavior.

Testing

The bloc_test package lets you assert the exact sequence of states a Bloc or Cubit emits in response to given events, without touching any widget code:

blocTest<CounterBloc, int>(
  'emits [1] when Increment is added',
  build: () => CounterBloc(),
  act: (bloc) => bloc.add(Increment()),
  expect: () => [1],
);

Cubit or Bloc?

Start with Cubit — it’s part of the same package, so switching later doesn’t mean a new dependency. Move to full Bloc when you specifically need the traceability of named events, such as for analytics logging, undo/redo, or a team convention that every state change must be triggered by an explicitly named event rather than a method call.