Flutter BLoC one-shot UI effects
For SnackBar, navigation after action, toast, dialog triggered by a BLoC
state change. Based on the
BLoC Todos tutorial
(lastDeletedTodo + listenWhen).
Pattern
A nullable payload field on the data-bearing state, plus a BlocListener
with listenWhen that fires exactly when the payload changes.
@freezed
sealed class TodosState with _$TodosState {
const factory TodosState.initial() = TodosInitial;
const factory TodosState.loading() = TodosLoading;
const factory TodosState.loaded({
required List<Todo> todos,
Todo? lastDeletedTodo, // entity payload
DateTime? lastRefreshedAt, // nonce, when payload can repeat
Exception? actionError, // inline error that preserves data
}) = TodosLoaded;
const factory TodosState.error({required Exception e}) = TodosError;
}
// Effect-carrying emit: fresh constructor, not copyWith.
// Each emit sets only the relevant marker; other effect fields reset to null.
emit(TodosState.loaded(todos: next, lastDeletedTodo: deleted));
emit(TodosState.loaded(todos: s.todos, actionError: e));
// Data-only update (optimistic UI): copyWith is fine.
// Never set an effect field via copyWith.
emit(s.copyWith(todos: optimisticTodos));
MultiBlocListener(
listeners: [
BlocListener<TodosBloc, TodosState>(
listenWhen: (prev, curr) {
if (curr is! TodosLoaded || curr.lastDeletedTodo == null) return false;
if (prev is! TodosLoaded) return true;
return curr.lastDeletedTodo != prev.lastDeletedTodo;
},
listener: (context, state) {
final s = state as TodosLoaded;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${s.lastDeletedTodo!.title} deleted')),
);
},
),
],
child: BlocBuilder<TodosBloc, TodosState>(...),
)
Rules
- UI effects only inside
BlocListener/BlocConsumer.listener. Never inbuildorBlocBuilder.builder. - One listener per effect type, grouped via
MultiBlocListener. - Effect = nullable payload field on the data-bearing state. Not a separate state variant. Not a sticky boolean.
- The payload must produce
!=on every emit that should re-fire the listener.Exceptionworks by identity (new instance every time); plainStringdoes not (value equality). Add aDateTimenonce when equality is value-based. - Effect-carrying emits go through a fresh constructor, not
copyWith.copyWithcarries old markers forward. - Builder reads persistent data only. Never check effect fields in
build. If a render branch is needed, model it as a regular field with its own name, not as an effect marker. - After
await, checkif (!context.mounted) return;. - BLoC / Cubit holds no
BuildContextand never calls UI directly.
Terminal state vs payload marker
If the current screen is gone after the effect (Navigator.pop,
pushReplacement, sheet close), a payload marker is wrong. Use a
separate terminal state variant.
@freezed
sealed class AddItemState with _$AddItemState {
const factory AddItemState.loaded({...}) = AddItemLoaded;
const factory AddItemState.success() = AddItemSuccess; // terminal
const factory AddItemState.error({required Exception e}) = AddItemError;
}
// listener: no mounted check needed when no await precedes pop
if (state is AddItemSuccess) Navigator.of(context).pop(true);
Checklist
- UI effects only inside
BlocListener/BlocConsumer.listener. - Effect is a nullable payload field, not a separate state variant.
- Payload produces
!=on every emit (verify equality semantics). - All effect-carrying emits go through a fresh constructor.
listenWhenchecks both the field change and the cross-variant transition.- Builder does not read effect fields.
- After
await,context.mountedis checked.
References
- BLoC Todos tutorial
- bloc#4073, sticky status discussion
Source: dtussupbayev/flutter-agentic-skills — distributed by TomeVault.