Reference location:
.claude/references/patterns/bloc-patterns.md
BLoC (Business Logic Component) is a state management pattern for Flutter.
State Pattern Comparison#
| Pattern | Pros | Cons | Recommended For |
|---|---|---|---|
| Freezed Union | Auto-generated copyWith, equality, pattern matching | Requires code generation | Clear state separation (recommended) |
| Sealed Class | No code generation, Dart 3.0+ native | Manual implementation needed | When avoiding code generation |
| Single State | Complex screens, multiple state combinations | Less clear state separation | Pagination, filtering, etc. |
State Definition#
Option A: Freezed Union State โ Recommended#
// {feature}_state.dart
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({
required User user,
}) = UserLoaded;
const factory UserState.error({
required Failure failure,
}) = UserError;
}
Option B: Sealed Class (Dart 3.0+)#
// {feature}_state.dart
sealed class UserState {
const UserState();
}
final class UserInitial extends UserState {
const UserInitial();
}
final class UserLoading extends UserState {
const UserLoading();
}
final class UserLoaded extends UserState {
const UserLoaded({required this.user});
final User user;
}
final class UserError extends UserState {
const UserError({required this.failure});
final Failure failure;
}
Option C: Single State (Complex screens)#
@freezed
class HomeState with _$HomeState {
const factory HomeState({
@Default(LoadingStatus.initial) LoadingStatus status,
@Default([]) List<Item> items,
Failure? failure,
@Default(false) bool isRefreshing,
@Default(false) bool hasMore,
@Default(0) int page,
}) = _HomeState;
const HomeState._();
bool get isInitial => status == LoadingStatus.initial;
bool get isLoading => status == LoadingStatus.loading;
bool get isLoaded => status == LoadingStatus.loaded;
bool get hasError => failure != null;
}
enum LoadingStatus { initial, loading, loaded, error }
Event Definition#
Option A: Freezed#
// {feature}_event.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_event.freezed.dart';
@freezed
class UserEvent with _$UserEvent {
const factory UserEvent.load({required int userId}) = UserLoad;
const factory UserEvent.refresh() = UserRefresh;
const factory UserEvent.update({required UpdateUserParams params}) = UserUpdate;
const factory UserEvent.delete() = UserDelete;
}
Option B: Sealed Class#
// {feature}_event.dart
sealed class UserEvent {
const UserEvent();
}
final class UserLoad extends UserEvent {
const UserLoad({required this.userId});
final int userId;
}
final class UserRefresh extends UserEvent {
const UserRefresh();
}
final class UserUpdate extends UserEvent {
const UserUpdate({required this.params});
final UpdateUserParams params;
}
UseCase Integration Pattern#
Standard pattern for using UseCase in BLoC.
Optional Constructor Injection โ Standard Pattern#
Enables pure unit testing by directly injecting mock UseCases during tests.
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc({
GetUserUseCase? getUserUseCase, // Optional: inject mock for testing
UpdateUserUseCase? updateUserUseCase,
}) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
_updateUserUseCase = updateUserUseCase ?? const UpdateUserUseCase(),
super(const UserInitial()) {
on<UserLoad>(_onLoad);
on<UserUpdate>(_onUpdate);
}
final GetUserUseCase _getUserUseCase;
final UpdateUserUseCase _updateUserUseCase;
Future<void> _onLoad(UserLoad event, Emitter<UserState> emit) async {
emit(const UserLoading());
final result = await _getUserUseCase(
GetUserParams(id: event.userId),
);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
}
When 8 or more UseCases: Bundle pattern
class DashboardUseCases {
const DashboardUseCases({
GetStatsUseCase? getStats,
GetChartDataUseCase? getChartData,
GetRecentItemsUseCase? getRecentItems,
// ... 8+ UseCases
}) : getStats = getStats ?? const GetStatsUseCase(),
getChartData = getChartData ?? const GetChartDataUseCase(),
getRecentItems = getRecentItems ?? const GetRecentItemsUseCase();
final GetStatsUseCase getStats;
final GetChartDataUseCase getChartData;
final GetRecentItemsUseCase getRecentItems;
}
class DashboardBloc extends Bloc<DashboardEvent, DashboardState> {
DashboardBloc({DashboardUseCases? useCases})
: _useCases = useCases ?? const DashboardUseCases(),
super(const DashboardState()) {
on<_Load>(_onLoad);
}
final DashboardUseCases _useCases;
}
Testing Advantages#
| Item | Description |
|---|---|
| Mock Target | Inject Mock UseCase directly via constructor |
| Isolation Level | UseCase level (precise isolation) |
| Setup Code | UserBloc(getUserUseCase: mockUseCase) |
| tearDown | Not needed (no GetIt global state) |
| Parallel Tests | Fully isolated, safe |
Stream Integration (SWR/Cache-First)#
Pattern A: emit.forEach + restartable() โ Recommended#
Using emit.forEach eliminates the need for manual StreamSubscription management.
The restartable() transformer automatically cancels the previous stream when parameters change.
import 'package:bloc_concurrency/bloc_concurrency.dart';
class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc({
GetItemsStreamUseCase? getItemsStream,
}) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
super(const HomeState()) {
on<HomeLoad>(
_onLoad,
transformer: restartable(), // Auto-cancel previous stream on new event
);
}
final GetItemsStreamUseCase _getItemsStream;
Future<void> _onLoad(HomeLoad event, Emitter<HomeState> emit) async {
// Show loading state only when no cache
if (state.items.isEmpty) {
emit(state.copyWith(status: LoadingStatus.loading));
}
// emit.forEach subscribes to the stream and automatically cleans up
await emit.forEach<Either<Failure, List<Item>>>(
_getItemsStream(GetItemsParams(categoryId: event.categoryId)),
onData: (result) => result.fold(
(failure) => state.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => state.copyWith(
status: LoadingStatus.loaded,
items: items,
lastUpdated: DateTime.now(),
),
),
);
}
// No need to override close()! emit.forEach handles cleanup automatically
}
How restartable() Works
1. add(HomeLoad(categoryId: 1)) โ ์คํธ๋ฆผA ๊ตฌ๋
์์ (์บ์ โ ์๋ฒ ์์ฐจ yield)
2. add(HomeLoad(categoryId: 2)) โ restartable()๊ฐ ํธ๋ค๋ฌ1 ์ทจ์ โ ์คํธ๋ฆผA ํด์
โ ํธ๋ค๋ฌ2 ์์ โ ์คํธ๋ฆผB ๊ตฌ๋
์์
| Transformer | Behavior | When to Use |
|---|---|---|
restartable() |
Cancel previous handler, start new handler | On parameter change (recommended) |
droppable() |
Ignore new events until previous handler completes | Prevent duplicate calls |
sequential() |
Sequential processing (queue) | When order must be guaranteed |
concurrent() | Parallel processing | Independent events |
Pattern B: Manual StreamSubscription (Legacy)#
Use only in special cases where emit.forEach cannot be used.
class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc() : super(const HomeState()) {
on<HomeLoad>(_onLoad);
on<_HomeItemsUpdated>(_onUpdated);
}
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
Future<void> _onLoad(HomeLoad event, Emitter<HomeState> emit) async {
emit(state.copyWith(status: LoadingStatus.loading));
await _subscription?.cancel();
_subscription = const GetItemsStreamUseCase().call(
GetItemsParams(categoryId: event.categoryId),
).listen(
(result) => add(_HomeItemsUpdated(result)),
onError: (error) => add(_HomeError(error)),
);
}
@override
Future<void> close() {
_subscription?.cancel(); // Manual cleanup required!
return super.close();
}
}
UI Integration#
BlocProvider#
@RoutePage()
class UserPage extends StatelessWidget {
const UserPage({@PathParam('id') required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => UserBloc()
..add(UserLoad(userId: userId)),
child: const UserView(),
);
}
}
BlocBuilder#
BlocBuilder<UserBloc, UserState>(
buildWhen: (previous, current) => previous != current,
builder: (context, state) {
return switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const LoadingIndicator(),
UserLoaded(:final user) => UserContent(user: user),
UserError(:final failure) => ErrorView(failure: failure),
};
},
)
BlocSelector#
// Subscribe to specific fields only
BlocSelector<HomeBloc, HomeState, List<Item>>(
selector: (state) => state.items,
builder: (context, items) => ItemList(items: items),
)
BlocListener#
BlocListener<UserBloc, UserState>(
listenWhen: (previous, current) =>
previous is! UserError && current is UserError,
listener: (context, state) {
if (state is UserError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.failure.message)),
);
}
},
child: const UserView(),
)
Cubit (Simple cases)#
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
Tests#
DI Decoupling Pattern Tests โ Recommended#
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
void main() {
late MockGetUserUseCase mockGetUser;
setUp(() {
mockGetUser = MockGetUserUseCase();
});
blocTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
setUp: () {
when(() => mockGetUser(any()))
.thenAnswer((_) async => right(testUser));
},
build: () => UserBloc(getUserUseCase: mockGetUser), // mock injection
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
}
Stream (emit.forEach) Test#
class MockGetItemsStreamUseCase extends Mock implements GetItemsStreamUseCase {}
blocTest<HomeBloc, HomeState>(
'emits cached then fresh data via SWR stream',
setUp: () {
when(() => mockGetItemsStream(any())).thenAnswer(
(_) => Stream.fromIterable([
right(cachedItems), // Cached data
right(freshItems), // Server data
]),
);
},
build: () => HomeBloc(getItemsStream: mockGetItemsStream),
act: (bloc) => bloc.add(const HomeLoad(categoryId: 1)),
expect: () => [
HomeState(status: LoadingStatus.loading),
HomeState(status: LoadingStatus.loaded, items: cachedItems),
HomeState(status: LoadingStatus.loaded, items: freshItems),
],
);
Selection Guide#
Clear state separation? โYesโ Freezed Union (recommended)
โNoโ Avoid codegen? โYesโ Sealed Class
โNoโ Single State
UseCase injection โ Optional Constructor Injection (standard)
Stream integration? โYesโ emit.forEach + restartable() (recommended)
โSpecial casesโ Manual StreamSubscription
Checklist#
- State definition (Freezed union / sealed class / single)
- Event definition (Freezed / sealed class)
- Implement BLoC (select UseCase integration pattern)
- Apply UseCase call pattern (Optional Constructor Injection)
isClosedcheck (required after await)-
Use
emit.forEach+restartable()for Stream integration - Optimize buildWhen/listenWhen
- Write tests (direct Mock UseCase constructor injection)
Referencing Agents#
/feature:bloc- BLoC state management/feature:presentation- Presentation Layer generation/inspector:bloc- BLoC runtime debugging