Flutter bloc one shot effects
Skill dtussupbayev/flutter-agentic-skills/skills/flutter-bloc-one-shot-effects
Pattern for one-shot UI effects (SnackBar, navigation, dialog) reacting to BLoC state transitions in flutter_bloc with freezed. Use when adding non-persistent UI reactions tied to a BLoC state change.From its SKILL.md
npx -y skills add dtussupbayev/flutter-agentic-skills --skill flutter-bloc-one-shot-effectsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
4.3 KB, 978 tokens by cl100k_base, as published. Nobody here has run it
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
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.