Bloc and Cubit: Predictable State with Explicit Transitions
After 30 days of building Flutter apps, you've probably felt the pain of managing state across screens and widgets. Bloc and Cubit give you a structured way to handle that pain—by making state changes predictable and testable. In this article, I'll walk you through when to use each pattern, how immutability keeps your app sane, and how to test state transitions with confidence.
Why State Management Matters
When I started with Flutter, I threw setState everywhere. It worked for simple widgets, but as screens grew more complex—especially with async operations, multiple data sources, and user interactions—the logic scattered across the widget tree became unmaintainable. Bloc and Cubit solve this by centralizing state and enforcing a single pattern: you describe what should happen, the pattern emits a new state, and your UI rebuilds predictably.
The difference between these two patterns is subtle but important. Cubit is simpler—call methods, emit states. Bloc is more explicit—listen for events, transition to states. I'll show you both, and you'll understand when each shines.
Cubit — Methods Emit New States
Cubit is the lightweight choice. You define a Cubit subclass, add methods that call emit() to produce new states, and bind it to your UI. No event layer, no boilerplate. Let me show you a real example: a counter that can increment, decrement, and reset.
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
See how clean that is? Each method is a direct action. Now in your widget:
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) {
return Text(
'Count: $count',
style: const TextStyle(fontSize: 24),
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
onPressed: () => context.read<CounterCubit>().decrement(),
child: const Icon(Icons.remove),
),
],
),
);
}
}
The widget doesn't hold any state. It just reads the Cubit, listens via BlocBuilder, and calls methods on user interaction. This works great for features like a shopping cart, a form validation counter, or a simple toggle. Use Cubit when your state transitions are straightforward and don't require complex event sequences.
Bloc — Events In, States Out
Bloc adds a layer: instead of calling methods directly, you send events. The Bloc receives an event, processes it (possibly asynchronously), and emits states. This explicitness is powerful for complex flows—like fetching user data, retrying on failure, or queuing requests.
Let's build a user fetch feature. You send a FetchUserEvent, the Bloc calls an API, and emits either a UserLoading, UserLoaded, or UserError state.
// Events
abstract class UserEvent {}
class FetchUserEvent extends UserEvent {
final int userId;
FetchUserEvent(this.userId);
}
class RefreshUserEvent extends UserEvent {}
// States
abstract class UserState {
const UserState();
}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final User user;
UserLoaded(this.user);
}
class UserError extends UserState {
final String message;
UserError(this.message);
}
// Bloc
class UserBloc extends Bloc<UserEvent, UserState> {
final UserRepository userRepository;
UserBloc({required this.userRepository}) : super(UserInitial()) {
on<FetchUserEvent>(_onFetchUser);
on<RefreshUserEvent>(_onRefreshUser);
}
Future<void> _onFetchUser(
FetchUserEvent event,
Emitter<UserState> emit,
) async {
emit(UserLoading());
try {
final user = await userRepository.fetchUser(event.userId);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
Future<void> _onRefreshUser(
RefreshUserEvent event,
Emitter<UserState> emit,
) async {
// Refresh logic here
}
}
Now in your widget, you dispatch events:
class UserScreen extends StatefulWidget {
final int userId;
const UserScreen({required this.userId});
@override
State<UserScreen> createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
@override
void initState() {
super.initState();
context.read<UserBloc>().add(FetchUserEvent(widget.userId));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('User')),
body: BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
if (state is UserLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is UserLoaded) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(state.user.name, style: const TextStyle(fontSize: 20)),
Text(state.user.email),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () =>
context.read<UserBloc>().add(RefreshUserEvent()),
child: const Text('Refresh'),
),
],
),
);
} else if (state is UserError) {
return Center(child: Text('Error: ${state.message}'));
}
return const SizedBox();
},
),
);
}
}
The key benefit: every event handler is in one place, and you can see exactly what state follows what event. You can also handle multiple events in a queue, skip events, or emit multiple states during a single event. I use Bloc for features like authentication, search with pagination, or real-time notifications.
Immutable States — Each State Is a New Object
Whether you use Cubit or Bloc, your states should be immutable. This means every new state is a completely new object, not a modified copy of the old one. This prevents bugs where you accidentally mutate a state and the UI doesn't rebuild.
The cleanest way is to use the freezed package. Let me show the user states rewritten with freezed:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_state.freezed.dart';
@freezed
class UserState with _$UserState {
const factory UserState.initial() = UserInitial;
const factory UserState.loading() = UserLoading;
const factory UserState.loaded(User user) = UserLoaded;
const factory UserState.error(String message) = UserError;
}
Run dart run build_runner build, and freezed generates immutable constructors, equality checks, and copyWith methods. Now your states are guaranteed immutable, and comparing states is trivial for testing.
Without freezed, you'd write:
@immutable
abstract class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final User user;
const UserLoaded(this.user);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserLoaded &&
runtimeType == other.runtimeType &&
user == other.user;
@override
int get hashCode => user.hashCode;
}
Freezed saves you boilerplate. I use it on every Bloc and Cubit in production apps.
Testing — bloc_test Verifies Emitted States
This is where Bloc and Cubit shine. The bloc_test package lets you verify state transitions without touching the UI. You arrange initial conditions, act (send an event or call a method), and assert the emitted states.
Here's a test for the UserBloc:
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
void main() {
group('UserBloc', () {
late UserBloc userBloc;
late MockUserRepository mockUserRepository;
setUp(() {
mockUserRepository = MockUserRepository();
userBloc = UserBloc(userRepository: mockUserRepository);
});
tearDown(() {
userBloc.close();
});
blocTest<UserBloc, UserState>(
'emits [UserLoading, UserLoaded] when FetchUserEvent succeeds',
build: () {
when(mockUserRepository.fetchUser(1))
.thenAnswer((_) async => User(id: 1, name: 'John', email: 'john@example.com'));
return userBloc;
},
act: (bloc) => bloc.add(FetchUserEvent(1)),
expect: () => [
isA<UserLoading>(),
isA<UserLoaded>()
.having((state) => state.user.name, 'name', 'John'),
],
);
blocTest<UserBloc, UserState>(
'emits [UserLoading, UserError] when FetchUserEvent fails',
build: () {
when(mockUserRepository.fetchUser(1))
.thenThrow(Exception('Network error'));
return userBloc;
},
act: (bloc) => bloc.add(FetchUserEvent(1)),
expect: () => [
isA<UserLoading>(),
isA<UserError>()
.having((state) => state.message, 'message', contains('Network')),
],
);
});
}
With bloc_test, you mock dependencies, dispatch events or call methods, and assert the exact sequence of states emitted. No faking widgets, no brittle UI tests—just pure logic. This is why I prefer Bloc and Cubit over setState for anything beyond trivial features.
Common Mistakes
- Mixing async and state emission: Don't await emit(). It returns a Future for ordering, but awaiting it blocks event processing. Just emit asynchronously and let the framework order them.
- Mutating state objects: Even if you forget @immutable, never modify a state object in place. Always create a new one. Freezed enforces this by design.
- Overusing Bloc when Cubit suffices: If your state transitions are simple and don't involve queuing or complex conditions, use Cubit. Bloc adds overhead you don't need.
- Forgetting to close Cubits and Blocs: Always call close() in tearDown during tests, and use BlocProvider in production so the framework closes them for you.
- Testing without freezed equality: Without freezed (or manual equality overrides), your state comparisons in tests fail silently. Always implement operator== and hashCode.
- Putting business logic in the widget: Keep widgets lean. All state transitions and API calls belong in the Bloc or Cubit. Widgets should only build UI based on the current state.
Key Takeaways
- Use Cubit for simple state flows where methods directly emit states. Perfect for toggles, counters, and straightforward UI logic.
- Use Bloc when you need explicit events, async operations, and queueing. Ideal for authentication, data fetching, and complex user flows.
- Keep all states immutable. Use freezed to generate boilerplate and guarantee correctness.
- Test with bloc_test. Arrange mocks, act by dispatching events, and assert the exact sequence of states. No UI needed.
- Centralize state logic outside widgets. BlocBuilder and BlocListener just react to state; they don't own it.
- Always close Cubits and Blocs when done. Use BlocProvider or test tearDown to ensure cleanup.
By day 30, you've moved past random state management. Bloc and Cubit give you a predictable, testable, scalable way to manage state as your app grows. In my experience with production e-commerce and SaaS platforms, this pattern is the difference between code that's easy to maintain and code that's a nightmare.






Comments
Loading comments…