BlocSignal Patterns
State management for Flutter uses **`bloc_signals`** — BLoC's structural discipline (Event/State/handler
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 for BlocSignal/CubitSignal |
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() |
Supersedes the in-flight handler's emissions — it does NOT cancel a subscription that handler opened | Parameter change in an await-only handler. Never on a handler that opens a subscription — see Stream Integration |
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 a generation token ✅ 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); // no transformer — the generation token below owns the restart
}
final GetItemsStreamUseCase _getItemsStream;
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
int _generation = 0; // one counter per subscription slot
Future<void> _onLoad(HomeLoad event, void Function(HomeState) emit) async {
final generation = ++_generation; // claim the slot before the first await
final previous = _subscription;
_subscription = null;
await previous?.cancel();
if (isClosed) return; // close() does NOT cancel an in-flight handler
if (generation != _generation) return; // a newer run owns the slot — do NOT subscribe
if (stateValue.items.isEmpty) {
emit(stateValue.copyWith(status: LoadingStatus.loading));
}
final stream = _getItemsStream(GetItemsParams(categoryId: event.categoryId));
// `late final` is safe here — `onDone` cannot fire before `listen` returns.
late final StreamSubscription<Either<Failure, List<Item>>> subscription;
subscription = stream.listen(
(result) {
if (isClosed) return;
emit(result.fold(
(failure) => stateValue.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => stateValue.copyWith(
status: LoadingStatus.loaded,
items: items,
lastUpdated: DateTime.now(),
),
));
},
onError: (Object error, StackTrace stackTrace) {
// Defensive: the cache layer is contracted to surface failures as a
// `Left`. Without this handler the error escapes to the zone instead
// of the container, and the screen stays on `loading`.
Log.e('SWR stream error', error: error, stackTrace: stackTrace);
if (isClosed) return;
emit(stateValue.copyWith(
status: LoadingStatus.error,
failure: Failure.unexpected(error.toString()),
));
},
onDone: () {
// Deregister only while the slot still holds THIS subscription.
if (identical(_subscription, subscription)) _subscription = null;
},
cancelOnError: false, // keep watching after a recoverable error
);
// emit() and listen() above can synchronously re-dispatch the same event —
// or synchronously close the container. (The shared SWR mixin checks only
// the generation here; `isClosed` is extra defence for a hand-rolled slot.)
if (isClosed || generation != _generation) {
await subscription.cancel(); // close the one just opened, then step aside
return;
}
final stale = _subscription;
if (stale != null && !identical(stale, subscription)) {
unawaited(stale.cancel()); // dart:async — never overwrite a live subscription
}
_subscription = subscription;
}
/// An explicit cancel (filter reset, leaving the screen) must bump the
/// generation too — otherwise an in-flight `_onLoad` subscribes after it.
Future<void> cancelLoad() {
_generation++;
final subscription = _subscription;
_subscription = null;
return subscription?.cancel() ?? Future<void>.value();
}
@override
Future<void> close() async {
if (isClosed) return;
final subscription = _subscription;
_subscription = null;
// Bumping the generation here would not close the window: it only stops a
// run that claimed its generation BEFORE the bump — one that claims a fresh
// generation after it compares equal and passes those checks regardless.
// `super.close()` sets `isClosed` synchronously, so calling it FIRST is what
// makes a run resuming below step aside at the guards above.
await super.close(); // always await super
await subscription?.cancel();
}
}
No transformer — and restartable() would add nothing here. The generation check sits ahead of every
emit, and nothing between it and the registration awaits, so a superseded run never emits at all and
the subscription it opened is cancelled outright. Nothing is left for restartable()
to discard. What
would remain is its risk: its token lives on the on<E> registration, not on your subscription slot,
and is permanent once superseded — one registration driving two slots, or a handler that returns early,
would silence a still-live subscription without cancelling it.
This form returns once the subscription is registered, not when the stream completes. With no transformer
that does not matter, but it changes behaviour if you swap in droppable()
or sequential().
Why a generation token — overlapping handlers. add() does not await the handler, so several runs
of the same handler can be alive at once, and every await inside one is a window in which a second run
starts. Without the token:
| Step | run #1 | run #2 |
|---|---|---|
await previous?.cancel() |
leaves the slot empty | starts here, also sees an empty slot |
listen(...) | opens subscription A | opens subscription B |
| store into the slot | writes A | writes B over A |
Nothing references A any more, so close(), the next restart and cancelLoad()
all reach B only. An
endpoint SWR stream (cache → server) completes on its own and that orphan dies quietly; a
perpetual
stream (a local-first watch()) never completes, so the orphan keeps emitting for the whole lifetime of
the container.
❌ the shape this replaces — the cancel runs, but nothing versions its result:
await _subscription?.cancel(); // the slot is empty for the whole await
_subscription = stream.listen(...); // a run started during it overwrites this, uncancelled
Assigning the field is necessary but not sufficient — the assigned-but-unversioned form above is
what produced the incident below. (The example this section replaces was worse still: it declared
_subscription and never assigned it, so that cancel and close()'s were both permanent no-ops.)
Incident — unibook #13475. Two
pageChangeddispatches overlapped within microtasks when a book opened: a page-restore listener, and the open handler re-dispatching after anawait. Both runs got past the cancel, each opened a subscription, and the later registration overwrote the earlier one without cancelling it. The orphan had been harmless while the source was an endpoint stream; once that data moved to a local-first perpetualwatch()it survived, and because the store invalidates per table (drift) — every unrelated row write re-fires every watcher — it kept restoring notes from a previous page onto the screen. Fixed in PR #13478 with a per-key generation counter in the shared SWR mixin, plus a bloc-level guard that refuses to merge data for pages outside the current spread.⚠️ Diagnosing it needs a hot restart, not a hot reload: an already-open stream callback keeps running its old closure, so instrumentation added by a reload never fires and the orphan looks like an emission with no source. What named it: logging a subscription id plus
identical(_subscription, subscription)on every emission (an orphan reportsregistered=false), and anonChangeoverride that printsStackTrace.currentwhen out-of-range data lands.
Need async work between emissions? await for is the alternative — but only for streams that
terminate. A perpetual watch() never lets the loop return, and the loop form cannot populate
_subscription, so close() has no handle on it. See
swr-pattern §9-3 for that form and §9-4 for exactly what
restartable() does and does not cancel.
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) {
// CoUI 토스트 — CoUIApp 이 ToastLayer 를 깐다
showToast(
context: context,
builder: (context, overlay) => Toast(
message: state.failure.message,
onDismiss: overlay.close,
),
);
}
},
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→ subscription + generation token + 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 + generation token (no transformer),
StreamBlocSignal, orcreateEffect -
Generation token re-checked after the cancel and again before registering, and bumped by every explicit cancel path (
cancelLoad()) -
close()detaches the slot, awaitssuper.close()before cancelling, and cancels non-createEffectresources - 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