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

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#

PackageRole
bloc_signals Pure Dart core — BlocSignal<E, S>, CubitSignal<S>
bloc_signals_flutter Flutter bindings — providers, builders, listeners, context extensions
bloc_signals_testblocSignalTest 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 the equals: callback and an equals() subclass override.


State Pattern Comparison#

PatternProsConsRecommended 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#

// {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 in bloc_signals. The handler signature is FutureOr<void> Function(E event, void emit(S newState)). When extracting a named handler method, type the second parameter as void Function(S) — not Emitter<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(),
);
TransformerBehaviorWhen 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#

ItemDescription
Mock TargetInject Mock UseCase directly via constructor
Isolation LevelUseCase level (precise isolation)
Setup CodeUserBloc(getUserUseCase: mockUseCase)
tearDownNot needed (no GetIt global state)
Parallel TestsFully isolated, safe

Stream Integration (SWR/Cache-First)#

⚠️ emit.forEach does not exist in bloc_signals. It was a package:bloc Emitter API. 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.

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:

Steprun #1run #2
await previous?.cancel() leaves the slot empty starts here, also sees an empty slot
listen(...)opens subscription Aopens subscription B
store into the slotwrites Awrites 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 pageChanged dispatches overlapped within microtasks when a book opened: a page-restore listener, and the open handler re-dispatching after an await. 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 perpetual watch() 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 reports registered=false), and an onChange override that prints StackTrace.current when 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 by createEffect. Own and dispose them explicitly, and never emit from an onDispose callback.


UI Integration#

BlocSignalProvider#

Ownership is decided by the constructor you pick:

FormCreates the blocCloses 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 with context.watch<T>().stateValue — use BlocSignalBuilder.

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 (not state)
  • Apply UseCase call pattern (Optional Constructor Injection)
  • isClosed check (required after every await)
  • Stream integration uses subscription + generation token (no transformer), StreamBlocSignal, or createEffect
  • Generation token re-checked after the cancel and again before registering, and bumped by every explicit cancel path (cancelLoad())
  • close() detaches the slot, awaits super.close() before cancelling, and cancels non-createEffect resources
  • Optimize buildWhen/listenWhen
  • Write tests (blocSignalTest or direct synchronous assertion)

Migration Appendix: classic BLoC → bloc_signals#

ComponentClassicbloc_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>)
ProviderBlocProviderBlocSignalProvider
Multi provider MultiBlocProvider MultiBlocSignalProvider
BuilderBlocBuilderBlocSignalBuilder
ListenerBlocListenerBlocSignalListener
ConsumerBlocConsumerBlocSignalConsumer
SelectorBlocSelectorBlocSignalSelector
ObserverBlocObserverBlocSignalObserver
Test helperblocTestblocSignalTest
Transformerspackage:bloc_concurrencybuilt in
Handler emit param Emitter<S> void Function(S) — no Emitter type exists
Stream in handler emit.forEach ❌ removed — see Stream Integration
Propagationasync (microtask)synchronous
Equal statesre-emittedde-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() inside build() — cache the reference in initState or 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

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

↑ ↓ 이동 · Enter 열기Esc 닫기