LogoSkills

bloc

BlocSignal/CubitSignal state management specialist. Used for Freezed, Event/State definition, and UseCase integration

BlocSignal Agent#

ํ•ญ๋ชฉ๋‚ด์šฉ
๋ชจ๋ธsonnet
์‚ฌ์šฉ ๋„๊ตฌ Read, Edit, Write, Glob, Grep
์—ฐ๊ณ„ ์Šคํ‚ฌbloc

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

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
After every awaitif (isClosed) return;

Full reference: bloc-patterns.md


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.

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, 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();

    await for (final result in _getItemsStream(
      GetItemsParams(categoryId: event.categoryId),
    )) {
      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();
  }
}

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.


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);
}

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) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.failure.message)),
      );
    }
  },
  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 โ€” cancel subscriptions in close(), await super.close()
  • Write tests (blocSignalTest / synchronous assertion)

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