bloc
BlocSignal/CubitSignal state management specialist. Used for Freezed, Event/State definition, and UseCase integration
| 항목 | 내용 |
|---|---|
| 모델 | sonnet |
| 사용 도구 | Read, Edit, Write, Glob, Grep |
| 연계 스킬 | flutter-patterns, swr-pattern |
An agent specializing in state management using bloc_signals (BlocSignal /
CubitSignal) and Freezed.
Triggers#
@bloc or auto-activated on detecting the following keywords:
- BlocSignal, CubitSignal, BLoC, Cubit, state management
- Event, State, Freezed
- emit, add, stateValue
Role#
-
Container Design
- Event/State definition
- Freezed or sealed class integration
- Async processing
-
Pattern Application
- UseCase Optional Constructor Injection
- Either handling
- Error state management
-
Optimization
- buildWhen/listenWhen
- BlocSignalSelector
- Memory management (invariant: one live subscription per key)
Structure#
presentation/bloc/
├── {feature}_bloc.dart # BlocSignal class
├── {feature}_event.dart # Event definitions
└── {feature}_state.dart # State definitions
Non-Negotiables (bloc_signals)#
| Rule | Detail |
|---|---|
| Named initial state | super(initialState: ...) — positional super(...) does not compile |
| Read state |
stateValue
inside handlers/methods.
state
is a
ReadonlySignal<S>
|
| Handler emit param | void Function(S) — there is no Emitter<S> type |
| Synchronous emit | State lands in the same execution block; no frame delay |
| Auto de-dup | emit(next) is a no-op when next == stateValue |
| Transformers |
droppable
/
sequential
/
restartable
are built in — no
bloc_concurrency
.
restartable()
discards a superseded handler's
emissions only
: the
StreamSubscription
that handler opened stays open, and its token lives on the
on<E>
registration, not on your subscription key
|
| Stream restart |
cancel()
→ re-
listen()
spans an
await
, and handlers overlap. Guard the slot with a generation token — see Stream Integration (unibook #13475)
|
| After every await | if (isClosed) return; |
Full reference: bloc-patterns.md — its Stream Integration "Pattern A" carries the same generation-token sequence, plus the overlapping-handler timeline table.
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;
}
Implementation (Optional Constructor Injection) ✅ Standard#
// {feature}_bloc.dart
import 'package:bloc_signals/bloc_signals.dart';
class UserBloc extends BlocSignal<UserEvent, UserState> {
UserBloc({
GetUserUseCase? getUserUseCase,
UpdateUserUseCase? updateUserUseCase,
AuthBloc? authBloc, // cross-container dependency: nullable
}) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
_updateUserUseCase = updateUserUseCase ?? const UpdateUserUseCase(),
_authBloc = authBloc,
super(initialState: const UserInitial()) {
on<UserLoad>(_onLoad);
on<UserRefresh>(_onRefresh);
on<UserUpdate>(_onUpdate);
}
final GetUserUseCase _getUserUseCase;
final UpdateUserUseCase _updateUserUseCase;
final AuthBloc? _authBloc;
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 await!
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
Future<void> _onRefresh(UserRefresh event, void Function(UserState) emit) async {
final currentState = stateValue;
if (currentState is! UserLoaded) return;
final result = await _getUserUseCase(
GetUserParams(id: currentState.user.id),
);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
Future<void> _onUpdate(UserUpdate event, void Function(UserState) emit) async {
final currentState = stateValue;
if (currentState is! UserLoaded) return;
emit(const UserLoading());
final result = await _updateUserUseCase(event.params);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
}
Stream Integration (SWR/Cache-First)#
⚠️
emit.forEachwas apackage:blocAPI and does not exist inbloc_signals. Consume the stream explicitly and guard every async gap.
⚠️ Cancel-then-resubscribe spans an
await— claim a generation token first.add()does not await the handler, so two runs of the same handler can overlap — with the default transformer and withrestartable()alike. If the second enters while the first is parked onawait previous.cancel(), both see "no previous subscription", each opens its own, and whichever registers last overwrites the other without cancelling it. The overwritten subscription is tracked nowhere, so neitherclose()nor the next restart reaches it: it keeps consuming a permanent watch (Driftwatch(), local-first tables) and re-emits stale data into state. Observed in unibook #13475 (sticky notes from a previous spread stayed on screen on the iPad two-page view); fixed in PR #13478.⛔ Do not reach for
sequential()/droppable()to serialize this instead. When a handler's Future is tied to its subscription's lifetime (as the shared mixin'semitSwrStreamis — it completes only atonDone), the mutex / in-flight flag stays held for as long as the watch lives and that event type stops being processed at all.
import 'dart:async';
import 'package:bloc_signals/bloc_signals.dart';
class HomeBloc extends BlocSignal<HomeEvent, HomeState> {
HomeBloc({
GetItemsStreamUseCase? getItemsStream,
}) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
super(initialState: const HomeState()) {
on<HomeLoad>(_onLoad);
}
final GetItemsStreamUseCase _getItemsStream;
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
// One counter per subscription slot. Keep a `Map<Object, int>` keyed by
// `subscriptionKey` (conventionally `event.runtimeType`) once a handler owns
// more than one stream.
int _generation = 0;
Future<void> _onLoad(HomeLoad event, void Function(HomeState) emit) async {
// ① claim the slot before the first await: take the generation, then detach
// the previous subscription *before* awaiting its cancel. A later call
// must not find this slot still occupied, and this call must not null out
// a newer registration when it resumes — both halves belong to the same
// synchronous stretch. `restartable()` will not do any of this for you.
final generation = ++_generation;
final previous = _subscription;
_subscription = null;
await previous?.cancel();
if (isClosed) return;
// ② a newer call arrived during the await — this one is stale, open nothing.
if (generation != _generation) return;
if (stateValue.items.isEmpty) {
emit(stateValue.copyWith(status: LoadingStatus.loading));
}
// Stream events are always delivered after `listen()` returns, so reading
// `subscription` inside the callbacks below is safe.
late final StreamSubscription<Either<Failure, List<Item>>> subscription;
subscription = _getItemsStream(
GetItemsParams(categoryId: event.categoryId),
).listen(
(result) {
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(),
),
));
},
// Defensive: the cache layer is contracted to surface failures as `Left`,
// so a raw stream error means that contract broke. Without this handler it
// escapes to the zone and never reaches state.
onError: (Object error, StackTrace stackTrace) {
if (isClosed) return;
emit(stateValue.copyWith(
status: LoadingStatus.error,
failure: UnexpectedFailure(error.toString()),
));
},
// ⑤ deregister only while the slot still holds *this* subscription —
// never wipe a newer one's registration.
onDone: () {
if (identical(_subscription, subscription)) _subscription = null;
},
cancelOnError: false, // keep a permanent watch alive past an error
);
// ③ re-check right before registering — the `emit` and `listen` above are
// synchronous, so they can re-dispatch the same event *or* close the
// container from a listener. The canonical `SwrBlocSignalMixin` checks
// only the generation here; the `isClosed` half is extra defence you add
// by hand.
if (isClosed || generation != _generation) {
await subscription.cancel();
return;
}
// ④ anything still parked in the slot is not ours — cancel it, never overwrite.
final stale = _subscription;
if (stale != null && !identical(stale, subscription)) {
unawaited(stale.cancel());
}
_subscription = subscription;
}
@override
Future<void> close() async {
if (isClosed) return;
// Detach, then close *before* cancelling. `CubitSignalMixin.close()` sets
// `isClosed` on the line right after its re-entry guard and awaits nothing,
// so a run parked on the `cancel()` below resumes into an already-closed
// container and retreats at ② (post-await staleness) / ③ (pre-registration
// re-check). Bumping the generation here instead would not close that
// window: a call that *starts* after `close()` picks up the newest
// generation and sails through both checks — only `isClosed` stops it.
final subscription = _subscription;
_subscription = null;
await super.close();
await subscription?.cancel();
}
}
⚠️
restartable()is deliberately absent above. Its token lives on theon<E>registration, not on your subscription key, so a handler that multiplexes two keys would have key B's execution permanently silence key A's still-live subscription. The generation token already performs the real restart and cancels what it opened, sorestartable()has nothing left to discard here: a superseded run steps aside at ② before it emits anything. Its gate is permanent and registration-scoped, so the moment this handler grows a second key or an earlyreturn, it silently blocks a live subscription's emits. Attach it only to handlers that open no subscription (reloads, search-as-you-type, polling).
⑥ An explicit cancel path (clearing a filter, leaving a screen) must bump the generation too — otherwise
a call already parked on its await reopens the stream you just cancelled.
close() is the exception:
it shuts that window by running super.close() ahead of its own cleanup, not by bumping the counter.
Future<void> _cancelItems() {
++_generation; // ⑥
final previous = _subscription;
_subscription = null;
return previous?.cancel() ?? Future<void>.value();
}
// ❌ no token — two overlapping calls both open a stream; the loser is
// overwritten and never cancelled (zombie subscription).
await _subscription?.cancel();
_subscription = _getItemsStream(params).listen(...);
// ❌ a second, separate defect — `await for` never stores the subscription, so
// `_subscription` stays null and `close()` cancels nothing.
await _subscription?.cancel();
await for (final result in _getItemsStream(params)) { ... }
When the whole container is fed by one stream, prefer StreamBlocSignal(stream, initialState: ...)
(stream is positional). When the source is already a signal, use createEffect
— it is owned and
disposed by the container.
Rather than hand-rolling the token per container, mix in the shared helper (SwrBlocSignalMixin
/
SwrCubitSignalMixin in unibook's package/core), which keeps the counter per
subscriptionKey and
holds the "one live subscription per key" invariant. Background on why restartable()
cannot own the
cleanup: swr-pattern skill §9-4; the overlap mechanism, the six
invariants and the mixin's regression tests are §9-7 of that skill (§9-2 shows the same hand-rolled
sequence as above, and §9-3's await for option deliberately has no slot for the token — it defers to
§9-2 for any path where calls can overlap).
CubitSignal (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);
}
CubitSignal has no event type, so a stream it owns lives in a single slot — the same generation-token
protocol applies (SwrCubitSignalMixin keeps one counter for exactly that reason).
UI Integration#
BlocSignalProvider#
@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(),
);
}
}
create: owns and closes the container. Use .value only when another owner controls the lifetime.
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),
};
},
)
BlocSignalSelector#
// Subscribe to specific fields only
BlocSignalSelector<HomeBloc, HomeState, List<Item>>(
selector: (state) => state.items,
builder: (context, items) => ItemList(items: items),
)
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(),
)
BlocSignalConsumer#
BlocSignalConsumer<UserBloc, UserState>(
listenWhen: (previous, current) => current is UserError,
listener: (context, state) {
// Show error snackbar
},
buildWhen: (previous, current) => current is! UserError,
builder: (context, state) {
return switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const LoadingIndicator(),
UserLoaded(:final user) => UserContent(user: user),
UserError() => const SizedBox.shrink(), // Don't build
};
},
)
context extensions#
context.read<UserBloc>().add(const UserRefresh()); // callbacks — no dependency
final name = context.select<UserBloc, String>(
(bloc) => bloc.stateValue is UserLoaded
? (bloc.stateValue as UserLoaded).user.name
: '',
);
⚠️
context.watch<T>()tracks instance replacement only — it does not subscribe to state. Never writecontext.watch<T>().stateValuein place of a builder.
Tests ✅ (Direct Mock UseCase Injection)#
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); // Direct mock injection
},
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
}
Synchronous assertion (no stream wait needed)#
test('refresh updates state synchronously', () {
final bloc = UserBloc(getUserUseCase: mockGetUser);
addTearDown(bloc.close);
bloc.add(const UserRefresh());
expect(bloc.stateValue, isA<UserLoading>());
});
Stream (SWR) Test#
class MockGetItemsStreamUseCase extends Mock implements GetItemsStreamUseCase {}
blocSignalTest<HomeBloc, HomeState>(
'emits cached then fresh data via SWR stream',
build: () {
when(() => mockGetItemsStream(any())).thenAnswer(
(_) => Stream.fromIterable([
right(cachedItems), // Cached data
right(freshItems), // Server data
]),
);
return HomeBloc(getItemsStream: mockGetItemsStream);
},
act: (bloc) => bloc.add(const HomeLoad(categoryId: 1)),
wait: const Duration(milliseconds: 10),
expect: () => [
const HomeState(status: LoadingStatus.loading),
HomeState(status: LoadingStatus.loaded, items: cachedItems),
HomeState(status: LoadingStatus.loaded, items: freshItems),
],
);
⚠️ Equal consecutive states are de-duplicated — never list the same state twice in
expect.
Pattern Selection Guide#
✅ Why Use Optional Constructor Injection#
- No getIt/injectable dependency (Pure DI)
- Direct mock UseCase injection in tests
- Container creation:
BlocSignalProvider(create: (_) => Bloc()) - getIt.reset() not needed in tearDown
Prohibited Patterns#
- Direct Repository access from the container
getIt<T>()calls- @injectable annotations
emit()/add()insideWidget.build()effect()/computed()created insidebuild()
Checklist#
- State definition (Freezed union / sealed class / single)
- Event definition (Freezed / sealed class)
-
Extend
BlocSignal<E, S>/CubitSignal<S> super(initialState: ...)named argument-
stateValuefor state reads; handler emit typedvoid Function(S) - Optional Constructor Injection for UseCases
isClosedcheck (required after await)- Create container via
BlocSignalProvider(create:) - Optimize buildWhen/listenWhen
-
Memory management —
close()detaches the slot,await super.close(), then cancels - Stream restart guarded by a generation token (see Stream Integration) — explicit cancel paths bump it too
-
No
transformer:on a handler that opens a subscription — the generation token does the cancelling - Write tests (
blocSignalTest/ synchronous assertion)
Related Agents#
@feature: Feature module structure@test: BlocSignal testing@flutter-inspector-bloc: Runtime debugging