문서 목차
이 페이지에서목차

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#

  1. Container Design

    • Event/State definition
    • Freezed or sealed class integration
    • Async processing
  2. Pattern Application

    • UseCase Optional Constructor Injection
    • Either handling
    • Error state management
  3. 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)#

RuleDetail
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 emitState lands in the same execution block; no frame delay
Auto de-dupemit(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 awaitif (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#

// {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.forEach was a package:bloc API and does not exist in bloc_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 with restartable() alike. If the second enters while the first is parked on await 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 neither close() nor the next restart reaches it: it keeps consuming a permanent watch (Drift watch(), 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's emitSwrStream is — it completes only at onDone), 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 the on<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, so restartable() 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 early return, 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 write context.watch<T>().stateValue in 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() inside Widget.build()
  • effect() / computed() created inside build()

Checklist#

  • State definition (Freezed union / sealed class / single)
  • Event definition (Freezed / sealed class)
  • Extend BlocSignal<E, S> / CubitSignal<S>
  • super(initialState: ...) named argument
  • stateValue for state reads; handler emit typed void Function(S)
  • Optional Constructor Injection for UseCases
  • isClosed check (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)

  • @feature: Feature module structure
  • @test: BlocSignal testing
  • @flutter-inspector-bloc: Runtime debugging

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기