Reference location:
.claude/references/patterns/bloc-patterns.md
State management for Flutter uses bloc_signals โ BLoC's structural discipline (Event/State/handler
separation) on top of signals primitives, giving synchronous, glitch-free state propagation.
Migrating from classic
package:bloc/package:flutter_bloc? See Migration Appendix at the bottom of this document.
Packages#
| Package | Role |
|---|---|
bloc_signals |
Pure Dart core โ BlocSignal<E, S>, CubitSignal<S> |
bloc_signals_flutter |
Flutter bindings โ providers, builders, listeners, context extensions |
bloc_signals_test | blocSignalTest declarative test helper |
bloc_signals_lint |
custom_lint rules (see dcm-bloc) |
Concurrency transformers (droppable, sequential, restartable) are
built into bloc_signals โ
bloc_concurrency is no longer a dependency.
Three Rules That Change How You Write Code#
These follow from signals-based propagation and are the source of most migration bugs.
1. emit is synchronous#
State updates land in the same execution block โ no microtask hop, no frame delay.
bloc.add(Increment());
print(bloc.stateValue); // already 1 โ no await needed
This is why tests can assert directly instead of awaiting a stream (see Tests).
2. Read state with stateValue, not state#
final int count = bloc.stateValue; // raw StateType
final ReadonlySignal<int> sig = bloc.state; // signal, for signals consumers
Inside handlers and methods you almost always want stateValue.
3. Equal states are de-duplicated automatically#
emit(next) is a no-op when next == stateValue. Immutable state with meaningful
== is part of the
contract โ a mutated-in-place object can silently suppress the update.
Override when you need every emit to notify:
final class AlwaysEmitCubit extends CubitSignal<StateModel> {
AlwaysEmitCubit(StateModel initial) : super(initialState: initial);
@override
bool equals(StateModel previous, StateModel current) => false;
}
Or supply a comparator per instance:
CubitSignal<UserModel>(initialState: user, equals: (a, b) => a.id == b.id);
options: SignalOptions(equality: ...)takes precedence over both theequals:callback and anequals()subclass override.
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 and Event definitions are unchanged by the move to bloc_signals โ both only require immutable
values with meaningful equality, which Freezed and sealed classes already provide.
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;
}
Event Routing#
Pick one routing style per container.
on<E> registration โ
Standard#
final class CounterBloc extends BlocSignal<CounterEvent, int> {
CounterBloc() : super(initialState: 0) {
on<Increment>((event, emit) => emit(stateValue + 1));
}
}
Registering the same exact event type twice throws StateError in every build mode. Matching uses
is E, so one event can match handlers registered for both a subtype and a supertype.
โ ๏ธ There is no
Emitter<S>type inbloc_signals. The handler signature isFutureOr<void> Function(E event, void emit(S newState)). When extracting a named handler method, type the second parameter asvoid Function(S)โ notEmitter<S>:on<UserLoad>(_onLoad); Future<void> _onLoad(UserLoad event, void Function(UserState) emit) async { ... }
onEvent exhaustive switch#
Use when a sealed event hierarchy needs compile-time coverage:
@override
FutureOr<void> onEvent(CounterEvent event) {
switch (event) {
case Increment():
emit(stateValue + 1);
}
return super.onEvent(event); // @mustCallSuper โ preserves the event zone
}
Concurrency transformers (built in)#
on<SearchQuery>(
(event, emit) async => emit(await _search(event.query)),
transformer: droppable(),
);
| Transformer | Behavior | When to Use |
|---|---|---|
restartable() |
New event supersedes the in-flight handler | On parameter change (recommended) |
droppable() |
Drop new events while a handler runs | Prevent duplicate submits |
sequential() |
FIFO queue via Mutex |
When order must be guaranteed |
Mutex |
Zero-dependency async lock (protect(() => ...)) |
Custom synchronization |
UseCase Integration Pattern#
Optional Constructor Injection โ Standard Pattern#
Enables pure unit testing by directly injecting mock UseCases during tests.
class UserBloc extends BlocSignal<UserEvent, UserState> {
UserBloc({
GetUserUseCase? getUserUseCase, // Optional: inject mock for testing
UpdateUserUseCase? updateUserUseCase,
}) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
_updateUserUseCase = updateUserUseCase ?? const UpdateUserUseCase(),
super(initialState: const UserInitial()) {
on<UserLoad>(_onLoad);
on<UserUpdate>(_onUpdate);
}
final GetUserUseCase _getUserUseCase;
final UpdateUserUseCase _updateUserUseCase;
Future<void> _onLoad(UserLoad event, void Function(UserState) emit) async {
emit(const UserLoading());
final result = await _getUserUseCase(
GetUserParams(id: event.userId),
);
if (isClosed) return; // Required check after every await
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 BlocSignal<DashboardEvent, DashboardState> {
DashboardBloc({DashboardUseCases? useCases})
: _useCases = useCases ?? const DashboardUseCases(),
super(initialState: 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)#
โ ๏ธ
emit.forEachdoes not exist inbloc_signals. It was apackage:blocEmitterAPI. Handlers that consumed a stream through it must be rewritten using one of the patterns below.Full SWR guidance lives in swr-pattern; this section defines the mechanism those patterns build on.
Pattern A: Explicit subscription with freshness guards โ Recommended for handlers#
Use when the stream is scoped to one event (cache-then-server SWR).
class HomeBloc extends BlocSignal<HomeEvent, HomeState> {
HomeBloc({
GetItemsStreamUseCase? getItemsStream,
}) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
super(initialState: const HomeState()) {
on<HomeLoad>(_onLoad, transformer: restartable());
}
final GetItemsStreamUseCase _getItemsStream;
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
Future<void> _onLoad(HomeLoad event, void Function(HomeState) emit) async {
if (stateValue.items.isEmpty) {
emit(stateValue.copyWith(status: LoadingStatus.loading));
}
await _subscription?.cancel();
final stream = _getItemsStream(GetItemsParams(categoryId: event.categoryId));
await for (final result in stream) {
if (isClosed) return; // close() does NOT cancel an in-flight handler
emit(result.fold(
(failure) => stateValue.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => stateValue.copyWith(
status: LoadingStatus.loaded,
items: items,
lastUpdated: DateTime.now(),
),
));
}
}
@override
Future<void> close() async {
if (isClosed) return;
await _subscription?.cancel();
await super.close(); // always await super
}
}
restartable() still supersedes the previous handler when the parameter changes โ that part of the old
pattern is unchanged.
Pattern B: StreamBlocSignal when the whole container is stream-fed#
final itemsBloc = StreamBlocSignal<List<Item>>(
repository.watchItems(), // stream is POSITIONAL
initialState: const [],
);
Pattern C: createEffect when the source is already a signal#
createEffect is owned by the container and disposed by close():
final class MirrorCubit extends CubitSignal<int> {
MirrorCubit(this.source) : super(initialState: source.value) {
createEffect(() => emit(source.value));
}
final ReadonlySignal<int> source;
}
โ ๏ธ Raw
effect,computed, subscriptions, and timers are not registered bycreateEffect. Own and dispose them explicitly, and neveremitfrom anonDisposecallback.
UI Integration#
BlocSignalProvider#
Ownership is decided by the constructor you pick:
| Form | Creates the bloc | Closes it on dispose |
|---|---|---|
BlocSignalProvider(create: ..., lazy: true) (default) |
On first lookup | Yes |
BlocSignalProvider(create: ..., lazy: false) |
At provider init | Yes |
BlocSignalProvider.value(value: ...) |
No | No |
@RoutePage()
class UserPage extends StatelessWidget {
const UserPage({@PathParam('id') required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
return BlocSignalProvider(
create: (_) => UserBloc()..add(UserLoad(userId: userId)),
child: const UserView(),
);
}
}
Use .value only when another owner already controls the lifetime. Closing from both owners is a bug
even though close() is idempotent.
MultiBlocSignalProvider#
Individual providers need no placeholder child:
MultiBlocSignalProvider(
providers: [
BlocSignalProvider<AuthBloc>(create: (_) => AuthBloc()),
BlocSignalProvider<ThemeBloc>(create: (_) => ThemeBloc()),
],
child: const AppShell(),
)
BlocSignalBuilder#
BlocSignalBuilder<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),
};
},
)
Pass bloc: explicitly when the instance is not provided in the current subtree.
BlocSignalSelector#
// Subscribe to specific fields only
BlocSignalSelector<HomeBloc, HomeState, List<Item>>(
selector: (state) => state.items,
builder: (context, items) => ItemList(items: items),
)
Give the selected type meaningful equality and never mutate a selected object in place.
BlocSignalListener#
BlocSignalListener<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(),
)
The listener suppresses its initial run and fires only for later unequal states. listenWhen
receives
both values; the listener receives only the current state.
BlocSignalConsumer#
Combines the two and forwards both listenWhen and buildWhen. Unlike the bare listener, its provider
lookup does listen for instance replacement.
context extensions#
context.read<UserBloc>().add(UserLoad(userId: 1)); // no dependency โ use in callbacks
final canSubmit = context.select<FormCubit, bool>(
(cubit) => cubit.stateValue.canSubmit, // callback receives the BLOC, not the state
);
โ ๏ธ
context.watch<T>()does not subscribe to state. It only rebuilds when the provided instance changes. Never replace a builder withcontext.watch<T>().stateValueโ useBlocSignalBuilder.
context.select<B, R>takes 2 generic parameters (container type, return type). Keep select calls unconditional and in stable order โ subscriptions are cached by call index.
Cubit (Simple cases)#
class CounterCubit extends CubitSignal<int> {
CounterCubit() : super(initialState: 0);
void increment() => emit(stateValue + 1);
void decrement() => emit(stateValue - 1);
void reset() => emit(0);
}
Tests#
Full guidance: unit-testing.
Direct assertion (synchronous handlers)#
test('increments synchronously', () {
final bloc = CounterBloc();
addTearDown(bloc.close);
bloc.add(Increment());
expect(bloc.stateValue, 1); // no await โ emit already landed
});
Declarative: blocSignalTest#
import 'package:bloc_signals_test/bloc_signals_test.dart';
import 'package:mocktail/mocktail.dart';
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
void main() {
late MockGetUserUseCase mockGetUser;
setUp(() {
mockGetUser = MockGetUserUseCase();
});
blocSignalTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
build: () {
when(() => mockGetUser(any())).thenAnswer((_) async => right(testUser));
return UserBloc(getUserUseCase: mockGetUser); // mock injection
},
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
}
โ ๏ธ Automatic de-duplication means re-emitting an equal state produces no emission. Expectation lists must not include repeated equal states.
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? โHandler-scopedโ explicit subscription + restartable() + isClosed guard
โโWhole containerโ StreamBlocSignal
โโSource is a signalโ createEffect
Checklist#
- State definition (Freezed union / sealed class / single)
- Event definition (Freezed / sealed class)
-
Extend
BlocSignal<E, S>/CubitSignal<S> - Constructor uses named
super(initialState: ...) - State read via
stateValue(notstate) - Apply UseCase call pattern (Optional Constructor Injection)
isClosedcheck (required after every await)-
Stream integration uses subscription +
restartable(),StreamBlocSignal, orcreateEffect -
close()override cancels non-createEffectresources and awaitssuper.close() - Optimize buildWhen/listenWhen
- Write tests (
blocSignalTestor direct synchronous assertion)
Migration Appendix: classic BLoC โ bloc_signals#
| Component | Classic | bloc_signals |
|---|---|---|
| Base class (events) | Bloc<E, S> |
BlocSignal<E, S> |
| Base class (methods) | Cubit<S> |
CubitSignal<S> |
| Constructor | super(initial) |
super(initialState: initial) |
| Read state | state |
stateValue (state is ReadonlySignal<S>) |
| Provider | BlocProvider | BlocSignalProvider |
| Multi provider | MultiBlocProvider |
MultiBlocSignalProvider |
| Builder | BlocBuilder | BlocSignalBuilder |
| Listener | BlocListener | BlocSignalListener |
| Consumer | BlocConsumer | BlocSignalConsumer |
| Selector | BlocSelector | BlocSignalSelector |
| Observer | BlocObserver | BlocSignalObserver |
| Test helper | blocTest | blocSignalTest |
| Transformers | package:bloc_concurrency | built in |
| Handler emit param | Emitter<S> |
void Function(S) โ no Emitter type exists |
| Stream in handler | emit.forEach |
โ removed โ see Stream Integration |
| Propagation | async (microtask) | synchronous |
| Equal states | re-emitted | de-duplicated |
Progressive migration#
Legacy and new code can coexist:
// legacy Stream โ BlocSignal
final bridged = StreamBlocSignal<int>(legacyBloc.stream, initialState: 0);
// BlocSignal โ Stream, for a legacy StreamBuilder
final Stream<int> stream = myBlocSignal.toStream();
โ ๏ธ Never call
.toStream()or.toBlocSignal()insidebuild()โ cache the reference ininitStateor the container. Repeated calls re-subscribe and reset state.
Referencing Agents#
/cc-flutter:bloc- BlocSignal state management/cc-flutter:feature:presentation- Presentation Layer generation/cc-inspector:bloc- BlocSignal runtime debugging