Bloc Skill
Design, implement, and test state management using the bloc and flutter_bloc libraries.
When to Use
Use this skill when:
- Creating a new Cubit or Bloc for a feature.
- Modeling state (choosing between sealed classes and a single state class with status enum).
- Wiring
BlocBuilder,BlocListener,BlocConsumer, orBlocProviderin the widget tree. - Writing unit tests for a Cubit or Bloc.
- Deciding between Cubit and Bloc.
- Refactoring existing state management to follow bloc conventions.
1. Cubit vs Bloc
| Situation | Use |
|---|---|
| Simple state, no events needed | Cubit |
| Complex flows, event traceability needed | Bloc |
| Advanced event processing (debounce, throttle) | Bloc with event transformers |
Default to Cubit. Refactor to Bloc only when requirements grow.
2. Naming Conventions
Events (Bloc only)
- Named in past tense:
LoginButtonPressed,UserProfileLoaded. - Format:
BlocSubject+ optional noun + verb. - Initial load event:
BlocSubjectStarted(e.g.,AuthenticationStarted). - Base event class:
BlocSubjectEvent.
States
- Named as nouns (states are snapshots in time).
- Base state class:
BlocSubjectState. - Sealed subclasses:
BlocSubject+Initial|InProgress|Success|Failure.- Example:
LoginInitial,LoginInProgress,LoginSuccess,LoginFailure.
- Example:
- Single-class approach:
BlocSubjectState+BlocSubjectStatusenum (initial,loading,success,failure).
3. Modeling State
When to use a sealed class with subclasses
- States are well-defined and mutually exclusive.
- Type-safe exhaustive
switchis desired. - Subclass-specific properties exist.
dart@immutable sealed class LoginState extends Equatable { const LoginState(); } final class LoginInitial extends LoginState { @override List<Object?> get props => []; } final class LoginInProgress extends LoginState { @override List<Object?> get props => []; } final class LoginSuccess extends LoginState { const LoginSuccess(this.user); final User user; @override List<Object?> get props => [user]; } final class LoginFailure extends LoginState { const LoginFailure(this.message); final String message; @override List<Object?> get props => [message]; }
Handle all states exhaustively in the UI:
dartswitch (state) { case LoginInitial(): ... case LoginInProgress(): ... case LoginSuccess(:final user): ... case LoginFailure(:final message): ... }
When to use a single class with a status enum
- Many shared properties across states.
- Simpler, more flexible; previous data must be retained after failure.
dartenum LoginStatus { initial, loading, success, failure } @immutable class LoginState extends Equatable { const LoginState({ this.status = LoginStatus.initial, this.user, this.errorMessage, }); final LoginStatus status; final User? user; final String? errorMessage; LoginState copyWith({ LoginStatus? status, User? user, String? errorMessage, }) { return LoginState( status: status ?? this.status, user: user ?? this.user, errorMessage: errorMessage ?? this.errorMessage, ); } @override List<Object?> get props => [status, user, errorMessage]; }
State rules (both approaches)
- Extend
Equatableand pass all relevant fields toprops. - Copy
List/Mapproperties withList.of/Map.ofinsideprops. - Annotate with
@immutable. - Always emit a new instance; never reuse the same state object.
- Duplicate states are ignored by bloc — ensure meaningful state changes.
4. Cubit Implementation
dartclass LoginCubit extends Cubit<LoginState> { LoginCubit(this._authRepository) : super(const LoginState()); final AuthRepository _authRepository; Future<void> login(String email, String password) async { emit(state.copyWith(status: LoginStatus.loading)); try { final user = await _authRepository.login(email, password); emit(state.copyWith(status: LoginStatus.success, user: user)); } catch (e) { emit(state.copyWith(status: LoginStatus.failure, errorMessage: e.toString())); } } }
Rules:
- Only call
emitinside the Cubit/Bloc. - Public methods return
voidorFuture<void>only. - Keep business logic out of UI.
- When overriding
storagein aHydratedCubit, pass it as a named parameter:super(initialState, storage: storage).
5. Bloc Implementation
dartsealed class LoginEvent {} final class LoginSubmitted extends LoginEvent { LoginSubmitted({required this.email, required this.password}); final String email; final String password; } class LoginBloc extends Bloc<LoginEvent, LoginState> { LoginBloc(this._authRepository) : super(LoginInitial()) { on<LoginSubmitted>(_onLoginSubmitted); } final AuthRepository _authRepository; Future<void> _onLoginSubmitted( LoginSubmitted event, Emitter<LoginState> emit, ) async { emit(LoginInProgress()); try { final user = await _authRepository.login(event.email, event.password); emit(LoginSuccess(user)); } catch (e) { emit(LoginFailure(e.toString())); } } }
Rules:
- Trigger state changes via
bloc.add(Event()), not custom public methods. - Keep event handler methods private (
_onEventName). - Internal/repository events must be private and may use custom transformers.
6. Architecture
Three layers — each must stay in its own boundary:
Presentation → Business Logic (Cubit/Bloc) → Data (Repository → DataProvider)
- Data Layer: Repositories wrap data providers. Providers perform raw CRUD (HTTP, DB). Repositories expose clean domain objects.
- Business Logic Layer: Cubits/Blocs receive repository data and emit states. Inject repositories via constructor.
- Presentation Layer: Renders UI based on state. Handles user input by calling cubit methods or adding bloc events.
Rules:
- Blocs must not access data providers directly — only via repositories.
- No direct bloc-to-bloc communication. Use
BlocListenerin the UI to bridge blocs. - For shared data, inject the same repository into multiple blocs.
- Initialize
BlocObserverinmain.dart.
7. Flutter Bloc Widgets
| Widget | Use |
|---|---|
BlocProvider | Provide a bloc to a subtree |
MultiBlocProvider | Provide multiple blocs without nesting |
BlocBuilder | Rebuild UI on state change |
BlocListener | Side effects only (navigation, dialogs, snackbars) |
MultiBlocListener | Listen to multiple blocs without nesting |
BlocConsumer | Rebuild UI + side effects together |
BlocSelector | Rebuild only when a selected slice of state changes |
RepositoryProvider | Provide a repository to the widget tree |
MultiRepositoryProvider | Provide multiple repositories without nesting |
dartBlocProvider( create: (context) => LoginCubit(context.read<AuthRepository>()), child: LoginView(), ); BlocBuilder<LoginCubit, LoginState>( builder: (context, state) { return switch (state.status) { LoginStatus.loading => const CircularProgressIndicator(), LoginStatus.success => const HomeView(), LoginStatus.failure => Text(state.errorMessage ?? 'Error'), LoginStatus.initial => const LoginForm(), }; }, ); BlocListener<LoginCubit, LoginState>( listener: (context, state) { if (state.status == LoginStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(state.errorMessage ?? 'Login failed')), ); } }, child: LoginForm(), );
Rules:
- Use
context.read<T>()in callbacks (not inbuild). - Use
context.watch<T>()inbuildonly when necessary; preferBlocBuilder. - Never call
context.watchorcontext.selectat the root ofbuild— scope withBuilder. - Handle all possible states in the UI (initial, loading, success, failure).
8. Testing
Use bloc_test package. Mock repositories with mocktail.
dartimport 'package:bloc_test/bloc_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; class MockAuthRepository extends Mock implements AuthRepository {} void main() { group('LoginCubit', () { late AuthRepository authRepository; late LoginCubit loginCubit; setUp(() { authRepository = MockAuthRepository(); loginCubit = LoginCubit(authRepository); }); tearDown(() => loginCubit.close()); test('initial state should be LoginState with status initial', () { expect(loginCubit.state, const LoginState()); }); blocTest<LoginCubit, LoginState>( 'should emit [loading, success] when login succeeds', build: () { when(() => authRepository.login(any(), any())) .thenAnswer((_) async => fakeUser); return loginCubit; }, act: (cubit) => cubit.login('email@test.com', 'password'), expect: () => [ const LoginState(status: LoginStatus.loading), LoginState(status: LoginStatus.success, user: fakeUser), ], ); blocTest<LoginCubit, LoginState>( 'should emit [loading, failure] when login throws', build: () { when(() => authRepository.login(any(), any())) .thenThrow(Exception('error')); return loginCubit; }, act: (cubit) => cubit.login('email@test.com', 'wrong'), expect: () => [ const LoginState(status: LoginStatus.loading), isA<LoginState>().having((s) => s.status, 'status', LoginStatus.failure), ], ); }); }
Rules:
- Always call
tearDown(() => cubit.close()). - Use
blocTestfor state emission assertions. - Use
group()named after the class under test. - Name test cases with "should" to describe expected behavior.
- Register fallback values for custom types:
registerFallbackValue(MyEvent()).
9. Common Pitfalls
| Pitfall | Fix |
|---|---|
| Emitting the same state instance twice | Always create a new state object; bloc ignores duplicate emissions via ==. |
Calling context.watch inside callbacks | Use context.read in callbacks; watch is only valid inside build. |
Forgetting Equatable props | Add every field to props; missing fields cause silent state update bugs. |
| Mutable state fields | Keep state @immutable; use copyWith or new sealed subclass instances. |
| Business logic in widgets | Move all logic into the Cubit/Bloc; widgets only dispatch events or call methods. |
dart// BAD — mutating state in-place state.items.add(newItem); emit(state); // GOOD — emit a new state with copied list emit(state.copyWith(items: [...state.items, newItem]));


