| name | bloc-state-management |
| description | This skill should be used whenever the user works with state management in this Flutter project — creating or modifying a Cubit, a Bloc, a state class, or a use-case that a Cubit orchestrates. Trigger on mentions of Cubit, Bloc, "state for the screen", `flutter_bloc`, sealed state, freezed state, "loading state", "error state", "emit state", `bloc_test`, or any phrase implying that the application layer of a slice is being touched. Also trigger when the user asks how a screen should react to a server response, how to surface loading/error in the UI, or how to wire a button press to an async operation. Do not trigger for pure UI/widget work that does not change state shape. |
BLoC state management
When Cubit, when Bloc
Default to Cubit. A Cubit is a state machine without an event log, and 95% of
slices in this project need exactly that: a button is pressed, a use-case runs, the
state transitions, the UI reacts.
Switch to Bloc only when one of these is genuinely needed:
- Event traceability for auditing — the slice has compliance or analytics requirements
that need a record of every input event, not just every state.
- Debounce or throttle on incoming events — search-as-you-type, scroll-driven
pagination with rapid scroll.
EventTransformer handles this cleanly; manual
debouncing inside a Cubit method does not.
- Complex event streams that interleave from multiple sources — for example, a
screen reacting to both user input and a push notification stream.
If you cannot point to one of these reasons, use a Cubit. The simpler primitive is
the right default.
One Cubit, one slice
A Cubit lives in application/ of its slice and orchestrates exactly one
use-case, sometimes a tightly related pair. Do not build a "users feature mega-
Cubit" that handles list, create, edit, and delete — that violates the slice boundary
and makes the state shape unreadable.
If you find yourself adding a fifth field to a state class to track "which sub-flow
am I in", that is a signal: the slice is doing too much and should be split. See
the slice-decomposition skill for the decision procedure.
State shape: sealed via freezed
States are modelled as a sealed class generated by freezed. Every observable phase
of the slice is its own subtype. The compiler then forces every consumer to handle
every phase — that is the whole point of using sealed.
@freezed
sealed class AuthState with _$AuthState {
const factory AuthState.initial() = AuthInitial;
const factory AuthState.loading() = AuthLoading;
// CurrentUser comes from core/auth/, NOT User from features/users/.
// See agent_docs/architecture.md, "Bounded contexts".
const factory AuthState.authenticated({CurrentUser? currentUser}) = AuthAuthenticated;
const factory AuthState.error(Failure failure) = AuthError;
}
Notice that the state holds a Failure, not a String. The UI layer turns the
Failure into a localized message via slang. Storing pre-formatted error strings in
state mixes concerns and breaks localization.
Cubit body: orchestration only
The Cubit's job is orchestration, not business logic. It calls a use-case, folds
the Either<Failure, T> it returns, and emits the corresponding state. That is all.
class CreateUserCubit extends Cubit<CreateUserState> {
CreateUserCubit(this._createUser) : super(const CreateUserState.initial());
final CreateUserUseCase _createUser;
Future<void> submit(NewUserData data) async {
emit(const CreateUserState.loading());
final result = await _createUser(data);
emit(
result.fold(
(failure) => CreateUserState.error(failure),
(user) => CreateUserState.success(user),
),
);
}
}
Watch for the temptation to put validation, transformation, or branching logic into
the Cubit. If you do, two things go wrong: the Cubit becomes hard to test (you have to
mock the world), and the same logic gets duplicated when a second entry point appears.
Keep that logic in the use-case.
Use-cases return Either, not throw
Use-cases live in domain/usecases/ and return Future<Either<Failure, T>>. They do
not throw. Throwing breaks the type signature contract: a consumer reading the
signature should know what can go wrong.
If a use-case enforces a permission, see agent_docs/rbac.md for the pattern — the
use-case returns Left(Failure.permissionDenied()) and the Cubit handles it like any
other failure.
Communication between slices: only through the UI
When a slice needs to react to something that happened in another slice, the
communication goes through the UI, not through direct Cubit imports. The canonical
example: after CreateUserCubit emits CreateUserSuccess, the screen does
BlocListener<CreateUserCubit, CreateUserState>(
listener: (context, state) {
if (state is CreateUserSuccess) {
context.read<ListUsersCubit>().refresh();
context.router.maybePop();
}
},
child: ...,
)
The create_user slice does not import ListUsersCubit directly. It does not even
know that a list exists. The screen — which composes both — is the integration point.
This rule is non-negotiable. A direct import of one slice's Cubit from another slice's
files is a hard violation of the slice boundary and will be caught in code review.
Testing
Every Cubit has a bloc_test. Every use-case has a unit test. See
agent_docs/testing.md for the canonical patterns and the mocktail conventions.
Common mistakes
-
❌ A Cubit that calls Dio directly, bypassing the use-case. The Cubit must not know
what HTTP is.
-
❌ A state class that mixes a bool isLoading and a String? error in one record
type instead of using sealed subtypes. This is the pattern that freezed + sealed
is designed to eliminate.
-
❌ setState inside a widget that already has a Cubit. Pick one: if the state is
meaningful, lift it into the Cubit.
-
❌ Subscribing to another Cubit from inside a Cubit. State synchronisation between
slices belongs in the UI via BlocListener, not in the application layer.
-
❌ Emitting two states in a row without an await between them. The first state may
be coalesced and the UI never sees it. If you need a brief intermediate state for
UX, design it explicitly.
-
❌ Storing a localized String for the error in state. Store the Failure; let the
UI localize it.
-
❌ Nesting a BlocBuilder (or another BlocConsumer) inside an outer
BlocConsumer.builder. The outer builder already receives the current state as a
parameter — use it directly with local variables and conditional expressions. A nested
BlocBuilder introduces a frame boundary between the inner and outer rebuild cycles:
transitions that remove a widget from the tree (e.g., hiding a validation error
on idle) may silently not rebuild in tests even after tester.pump(). Flatten to a
single BlocConsumer and derive what you need from state:
// ❌ nested BlocBuilder — validation error text may not disappear on idle
BlocConsumer<MyCubit, MyState>(
listener: ...,
builder: (context, state) {
return Column(children: [
...fields,
BlocBuilder<MyCubit, MyState>(
buildWhen: (_, s) => s is MyValidationError || s is MyIdle,
builder: (context, state) =>
state is MyValidationError ? Text(state.message) : const SizedBox.shrink(),
),
]);
},
)
// ✅ single BlocConsumer — state is used directly
BlocConsumer<MyCubit, MyState>(
listener: ...,
builder: (context, state) {
final validationMessage =
state is MyValidationError ? state.message : null;
return Column(children: [
...fields,
if (validationMessage != null) Text(validationMessage),
]);
},
)
In widget tests, removing a widget via BlocConsumer requires two pump() calls
(one for the stream delivery, one for the frame propagation through the internal
BlocListener); adding a widget requires only one. This asymmetry is normal and
expected when using a single BlocConsumer — it is not a sign of a bug.
-
❌ Collecting cubit states into a list with stream.listen + await cancel() in
outside-in or integration tests. In bloc 9, BlocBase._stateController is an async
broadcast stream (sync: false); each emit() schedules the listener notification via
scheduleMicrotask. await cubit.someMethod() returns before that microtask fires, so
await sub.cancel() removes the subscription before the event is delivered — the last
state is silently dropped. Confirmed in bloc source and bloc_test internals (which
always add await Future<void>.delayed(Duration.zero) after act for exactly this
reason). Always use expectLater/emitsInOrder set up before the action:
// ❌ loses the last state
final emitted = <MyState>[];
final sub = cubit.stream.listen(emitted.add);
await cubit.load();
await sub.cancel();
expect(emitted.length, 2); // fails — Actual: <1>
// ✅ correct pattern
final expectation = expectLater(
cubit.stream,
emitsInOrder([
const MyState.loading(),
isA<MyStateLoaded>(),
]),
);
await cubit.load();
await expectation;
// individual field assertions go on cubit.state
final loaded = cubit.state as MyStateLoaded;
-
❌ Emergency only — when the outside-in test is a locked spec contract and cannot be
changed: if it uses stream.listen + cancel, add
await Future<void>.delayed(Duration.zero) after emit in the cubit method under
test. This flushes the microtask queue before the method's Future resolves, so the
caller that awaits it sees the stream event already delivered:
Future<void> setLocale(AppLocale locale) async {
final applied = await LocaleSettings.setLocale(locale);
await _storage.saveLocale(applied);
emit(applied);
await Future<void>.delayed(Duration.zero); // flush — only because test is locked
}
Do not apply this as a general rule to all cubit methods. Fix the test pattern
first; use this only when the test file genuinely cannot be touched.