LogoSkills

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 (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 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() 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#

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, 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 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) {
      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 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โ†’ 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 (not state)
  • Apply UseCase call pattern (Optional Constructor Injection)
  • isClosed check (required after every await)
  • Stream integration uses subscription + restartable(), StreamBlocSignal, or createEffect
  • close() override cancels non-createEffect resources and awaits super.close()
  • 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