bloc_after_effect

An extension of bloc that adds a one-shot UI side-effect stream alongside state — for navigation, dialogs, snackbars, and other fire-and-forget UI actions that don't belong in State.

  • Bloc decides what happened: emitEffect(ShowErrorDialog('Save failed'))
  • UI decides how to show it: showDialog(context: context, ...)

Install

flutter pub add bloc_after_effect

Usage

Define your effects, extend EffectBloc, and emit effects from event handlers:

abstract class ProfileEffect {}
class ShowErrorSnackBar extends ProfileEffect {
  ShowErrorSnackBar(this.message);
  final String message;
}

class ProfileBloc extends EffectBloc<ProfileEvent, ProfileState, ProfileEffect> {
  ProfileBloc() : super(ProfileState.initial()) {
    on<SavePressed>((event, emit) async {
      try {
        await repo.save();
        emitEffect(ShowSuccessSnackBar('Saved'));
      } catch (_) {
        emitEffect(ShowErrorSnackBar('Save failed'));
      }
    });
  }
}

Widgets

BlocEffectListener

Subscribes to EffectBloc.effects and calls a callback for each effect. Does not build any UI — just wraps a child.

BlocEffectListener<ProfileBloc, ProfileEffect>(
  listener: (context, effect) {
    if (effect is ShowErrorSnackBar) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(effect.message)),
      );
    } else if (effect is NavigateToEdit) {
      Navigator.of(context).push(
        MaterialPageRoute(builder: (_) => EditPage(userId: effect.userId)),
      );
    }
  },
  child: ProfilePageBody(),
)

Parameters:

  • listener (required) — void Function(BuildContext, E) callback per effect
  • child (required) — child widget
  • bloc (optional) — if omitted, resolved via context.read<B>()

BlocEffectBuilder

Combines BlocBuilder with an effect listener. Rebuilds the UI from state and reacts to effects. This is the main widget for most use cases.

BlocEffectBuilder<ProfileBloc, ProfileState, ProfileEffect>(
  effectListener: (context, effect) {
    if (effect is NavigateToEdit) Navigator.of(context).push(...);
  },
  buildWhen: (prev, curr) => prev.profile != curr.profile,
  builder: (context, state) => ProfileView(state: state),
)

Parameters:

  • builder (required) — Widget Function(BuildContext, S) builds UI from state
  • effectListener (required) — void Function(BuildContext, E) callback per effect
  • bloc (optional) — if omitted, resolved via context.read<B>()
  • buildWhen (optional) — filter for rebuilds

BlocEffectConsumer (migration only)

Not recommended for new code. Use BlocEffectBuilder instead.

A migration helper that combines BlocBuilder, an optional state listener, and an optional effect listener into a single widget. Use it when gradually transitioning from flutter_bloc's BlocConsumer to effects — it lets you keep the existing state listener while you move one-shot side-effects into proper Effects one callback at a time.

BlocEffectConsumer<ProfileBloc, ProfileState, ProfileEffect>(
  effectListener: (context, effect) {
    if (effect is NavigateToEdit) Navigator.of(context).push(...);
  },
  listener: (context, state) {                           // optional
    if (state.error != null) logger.error(state.error);
  },
  listenWhen: (prev, curr) => prev.error != curr.error,  // optional
  buildWhen: (prev, curr) => prev.profile != curr.profile, // optional
  builder: (context, state) => ProfileView(state: state),
)

Parameters:

  • builder (required) — Widget Function(BuildContext, S) builds UI from state
  • listener (optional) — void Function(BuildContext, S) state change callback
  • effectListener (optional) — void Function(BuildContext, E) callback per effect
  • bloc (optional) — if omitted, resolved via context.read<B>()
  • buildWhen (optional) — filter for rebuilds
  • listenWhen (optional) — filter for state listener

Migration from flutter_bloc

From BlocBuilder

If you only need to add effect handling to an existing BlocBuilder, use BlocEffectBuilder:

// Before
BlocBuilder<CounterBloc, CounterState>(
  builder: (context, state) => CounterView(state: state),
)

// After
BlocEffectBuilder<CounterBloc, CounterState, CounterEffect>(
  effectListener: (context, effect) { /* handle effects */ },
  builder: (context, state) => CounterView(state: state),
)

From BlocListener + BlocBuilder

Replace the pair with a single BlocEffectBuilder and move one-shot side-effects from the state listener into effectListener:

// Before
BlocListener<CounterBloc, CounterState>(
  listener: (context, state) {
    if (state.saved) ScaffoldMessenger.of(context).showSnackBar(...);
  },
  child: BlocBuilder<CounterBloc, CounterState>(
    builder: (context, state) => CounterView(state: state),
  ),
)

// After — side-effects are now proper Effects, not state
BlocEffectBuilder<CounterBloc, CounterState, CounterEffect>(
  effectListener: (context, effect) {
    if (effect is ShowSavedSnackBar) {
      ScaffoldMessenger.of(context).showSnackBar(...);
    }
  },
  builder: (context, state) => CounterView(state: state),
)

From BlocConsumer (gradual migration)

If you still need a state listener during migration, temporarily use BlocEffectConsumer. Once all one-shot side-effects have been moved to Effects, replace it with BlocEffectBuilder:

// Before
BlocEffectListener<CounterBloc, CounterEffect>(
  listener: (context, effect) { /* show snackbar */ },
  child: BlocConsumer<CounterBloc, CounterState>(
    listener: (context, state) { /* log */ },
    builder: (context, state) => CounterView(state: state),
  ),
)

// After
BlocEffectConsumer<CounterBloc, CounterState, CounterEffect>(
  effectListener: (context, effect) { /* show snackbar */ },
  listener: (context, state) { /* log */ },
  builder: (context, state) => CounterView(state: state),
)

Migration steps:

  1. Replace BlocConsumer<B, S> with BlocEffectConsumer<B, S, E> (add the Effect type parameter).
  2. Unwrap the surrounding BlocEffectListener — move its listener into effectListener.
  3. buildWhen / listenWhen carry over unchanged.

State vs Effect

State Effect
Stored Yes, in the Bloc No, fire-and-forget
Replayed on rebuild Yes No
Examples isLoading, items, error showDialog, navigate, showSnackBar

If the UI must render it on every build — it's State. If the UI must do it once — it's an Effect.

The effects stream is a broadcast stream: effects emitted with no active listener are dropped.

Documentation & example

License

MIT — see LICENSE.

Libraries

bloc_after_effect