LogoSkills

BLoC Patterns

BLoC (Business Logic Component) is a state management pattern for Flutter.

Reference location: .claude/references/patterns/bloc-patterns.md

BLoC (Business Logic Component) is a state management pattern for Flutter.


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

UseCase Integration Pattern#

Standard pattern for using UseCase in BLoC.

Optional Constructor Injection โœ… Standard Pattern#

Enables pure unit testing by directly injecting mock UseCases during tests.

class UserBloc extends Bloc<UserEvent, UserState> {
  UserBloc({
    GetUserUseCase? getUserUseCase,       // Optional: inject mock for testing
    UpdateUserUseCase? updateUserUseCase,
  }) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
       _updateUserUseCase = updateUserUseCase ?? const UpdateUserUseCase(),
       super(const UserInitial()) {
    on<UserLoad>(_onLoad);
    on<UserUpdate>(_onUpdate);
  }

  final GetUserUseCase _getUserUseCase;
  final UpdateUserUseCase _updateUserUseCase;

  Future<void> _onLoad(UserLoad event, Emitter<UserState> emit) async {
    emit(const UserLoading());

    final result = await _getUserUseCase(
      GetUserParams(id: event.userId),
    );

    if (isClosed) return;

    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 Bloc<DashboardEvent, DashboardState> {
  DashboardBloc({DashboardUseCases? useCases})
      : _useCases = useCases ?? const DashboardUseCases(),
        super(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)#

Using emit.forEach eliminates the need for manual StreamSubscription management. The restartable() transformer automatically cancels the previous stream when parameters change.

import 'package:bloc_concurrency/bloc_concurrency.dart';

class HomeBloc extends Bloc<HomeEvent, HomeState> {
  HomeBloc({
    GetItemsStreamUseCase? getItemsStream,
  }) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
       super(const HomeState()) {
    on<HomeLoad>(
      _onLoad,
      transformer: restartable(),  // Auto-cancel previous stream on new event
    );
  }

  final GetItemsStreamUseCase _getItemsStream;

  Future<void> _onLoad(HomeLoad event, Emitter<HomeState> emit) async {
    // Show loading state only when no cache
    if (state.items.isEmpty) {
      emit(state.copyWith(status: LoadingStatus.loading));
    }

    // emit.forEach subscribes to the stream and automatically cleans up
    await emit.forEach<Either<Failure, List<Item>>>(
      _getItemsStream(GetItemsParams(categoryId: event.categoryId)),
      onData: (result) => result.fold(
        (failure) => state.copyWith(
          status: LoadingStatus.error,
          failure: failure,
        ),
        (items) => state.copyWith(
          status: LoadingStatus.loaded,
          items: items,
          lastUpdated: DateTime.now(),
        ),
      ),
    );
  }
  // No need to override close()! emit.forEach handles cleanup automatically
}

How restartable() Works

1. add(HomeLoad(categoryId: 1))  โ†’ ์ŠคํŠธ๋ฆผA ๊ตฌ๋… ์‹œ์ž‘ (์บ์‹œ โ†’ ์„œ๋ฒ„ ์ˆœ์ฐจ yield)
2. add(HomeLoad(categoryId: 2))  โ†’ restartable()๊ฐ€ ํ•ธ๋“ค๋Ÿฌ1 ์ทจ์†Œ โ†’ ์ŠคํŠธ๋ฆผA ํ•ด์ œ
                                 โ†’ ํ•ธ๋“ค๋Ÿฌ2 ์‹œ์ž‘ โ†’ ์ŠคํŠธ๋ฆผB ๊ตฌ๋… ์‹œ์ž‘
TransformerBehaviorWhen to Use
restartable() Cancel previous handler, start new handler On parameter change (recommended)
droppable() Ignore new events until previous handler completes Prevent duplicate calls
sequential() Sequential processing (queue) When order must be guaranteed
concurrent()Parallel processingIndependent events

Pattern B: Manual StreamSubscription (Legacy)#

Use only in special cases where emit.forEach cannot be used.

class HomeBloc extends Bloc<HomeEvent, HomeState> {
  HomeBloc() : super(const HomeState()) {
    on<HomeLoad>(_onLoad);
    on<_HomeItemsUpdated>(_onUpdated);
  }

  StreamSubscription<Either<Failure, List<Item>>>? _subscription;

  Future<void> _onLoad(HomeLoad event, Emitter<HomeState> emit) async {
    emit(state.copyWith(status: LoadingStatus.loading));

    await _subscription?.cancel();
    _subscription = const GetItemsStreamUseCase().call(
      GetItemsParams(categoryId: event.categoryId),
    ).listen(
      (result) => add(_HomeItemsUpdated(result)),
      onError: (error) => add(_HomeError(error)),
    );
  }

  @override
  Future<void> close() {
    _subscription?.cancel();  // Manual cleanup required!
    return super.close();
  }
}

UI Integration#

BlocProvider#

@RoutePage()
class UserPage extends StatelessWidget {
  const UserPage({@PathParam('id') required this.userId, super.key});

  final int userId;

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => UserBloc()
        ..add(UserLoad(userId: userId)),
      child: const UserView(),
    );
  }
}

BlocBuilder#

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

BlocSelector#

// Subscribe to specific fields only
BlocSelector<HomeBloc, HomeState, List<Item>>(
  selector: (state) => state.items,
  builder: (context, items) => ItemList(items: items),
)

BlocListener#

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

Cubit (Simple cases)#

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset() => emit(0);
}

Tests#

import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';

class MockGetUserUseCase extends Mock implements GetUserUseCase {}

void main() {
  late MockGetUserUseCase mockGetUser;

  setUp(() {
    mockGetUser = MockGetUserUseCase();
  });

  blocTest<UserBloc, UserState>(
    'emits [loading, loaded] when load succeeds',
    setUp: () {
      when(() => mockGetUser(any()))
          .thenAnswer((_) async => right(testUser));
    },
    build: () => UserBloc(getUserUseCase: mockGetUser),  // mock injection
    act: (bloc) => bloc.add(const UserLoad(userId: 1)),
    expect: () => [
      const UserLoading(),
      UserLoaded(user: testUser),
    ],
  );
}

Stream (emit.forEach) Test#

class MockGetItemsStreamUseCase extends Mock implements GetItemsStreamUseCase {}

blocTest<HomeBloc, HomeState>(
  'emits cached then fresh data via SWR stream',
  setUp: () {
    when(() => mockGetItemsStream(any())).thenAnswer(
      (_) => Stream.fromIterable([
        right(cachedItems),   // Cached data
        right(freshItems),    // Server data
      ]),
    );
  },
  build: () => HomeBloc(getItemsStream: mockGetItemsStream),
  act: (bloc) => bloc.add(const HomeLoad(categoryId: 1)),
  expect: () => [
    HomeState(status: LoadingStatus.loading),
    HomeState(status: LoadingStatus.loaded, items: cachedItems),
    HomeState(status: LoadingStatus.loaded, items: freshItems),
  ],
);

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? โ”€Yesโ†’ emit.forEach + restartable() (recommended)
            โ””Special casesโ†’ Manual StreamSubscription

Checklist#

  • State definition (Freezed union / sealed class / single)
  • Event definition (Freezed / sealed class)
  • Implement BLoC (select UseCase integration pattern)
  • Apply UseCase call pattern (Optional Constructor Injection)
  • isClosed check (required after await)
  • Use emit.forEach + restartable() for Stream integration
  • Optimize buildWhen/listenWhen
  • Write tests (direct Mock UseCase constructor injection)

Referencing Agents#

  • /feature:bloc - BLoC state management
  • /feature:presentation - Presentation Layer generation
  • /inspector:bloc - BLoC runtime debugging