bloc_one_shot 0.1.0 copy "bloc_one_shot: ^0.1.0" to clipboard
bloc_one_shot: ^0.1.0 copied to clipboard

A side-effect system for Flutter BLoC. Provides a buffered EffectController, SideEffectMixin for Bloc/Cubit, and global EffectObserver for logging and analytics.

bloc_one_shot #

pub package License: MIT

Core Dart package for managing side effects in BLoC. Provides a buffered EffectController, SideEffectMixin for Bloc/Cubit, and a global EffectObserver.

For Flutter widgets (SideEffectListener, SideEffectConsumer), see flutter_bloc_one_shot.

For test utilities (blocEffectTest), see bloc_one_shot_test.

The Problem #

In Flutter BLoC, side effects like navigation, snackbars, and dialogs are ephemeral one-shot actions. Modeling them as persistent state causes:

Problem Example
Unnecessary rebuilds Widget tree rebuilds just to trigger a navigation
Ghost states ShowSnackbar lingers in state after dismissal
Cleanup boilerplate emit(state.copyWith(snackbar: null)) after every effect

The Solution #

bloc_one_shot introduces a dual-channel architecture: your Bloc emits State (what the screen IS) and Effect (what the screen DOES) through separate channels.

┌─────────────┐
│  Bloc/Cubit  │
├──────┬───────┤
│ State│ Effect│
│ stream│ stream│
└──┬───┴───┬───┘
   │       │
   ▼       ▼
Builder  Listener
(rebuilds) (fire & forget)

Installation #

dependencies:
  bloc_one_shot: ^0.1.0

Usage #

1. Define your effects #

sealed class AuthEffect {}

class NavigateToHome extends AuthEffect {}

class ShowErrorSnackbar extends AuthEffect {
  final String message;
  ShowErrorSnackbar(this.message);
}

2. Add the mixin to a Cubit #

class AuthCubit extends Cubit<AuthState>
    with SideEffectMixin<AuthState, AuthEffect> {

  Future<void> login(String email, String password) async {
    emit(AuthLoading());
    try {
      await _authRepo.login(email, password);
      emit(AuthSuccess());
      emitEffect(NavigateToHome());
    } catch (e) {
      emit(AuthInitial());
      emitEffect(ShowErrorSnackbar(e.toString()));
    }
  }
}

3. Add the mixin to a Bloc #

class AuthBloc extends Bloc<AuthEvent, AuthState>
    with SideEffectMixin<AuthState, AuthEffect> {

  AuthBloc() : super(AuthInitial()) {
    on<LoginRequested>((event, emit) async {
      emit(AuthLoading());
      try {
        await _authRepo.login(event.email, event.password);
        emit(AuthSuccess());
        emitEffect(NavigateToHome());
      } catch (e) {
        emit(AuthInitial());
        emitEffect(ShowErrorSnackbar(e.toString()));
      }
    });
  }
}

4. Listen to effects #

final cubit = AuthCubit();

// Subscribe to effects
cubit.effects.listen((effect) {
  switch (effect) {
    case NavigateToHome():
      print('Navigate!');
    case ShowErrorSnackbar(:final message):
      print('Error: $message');
  }
});

// Subscribe to state (works independently)
cubit.stream.listen((state) {
  print('State: $state');
});

API Reference #

SideEffectMixin<State, Effect> #

A mixin constrained to BlocBase<State>, compatible with both Bloc and Cubit.

Member Type Description
effects Stream<Effect> Broadcast stream of side effects
emitEffect(effect) void Emits a side effect — delivered live or buffered

The effect controller is automatically closed when the Bloc/Cubit closes.

EffectController<E> #

A broadcast stream controller with manual buffering. This is the engine behind SideEffectMixin.

Member Type Description
add(effect) void Adds an effect — delivers live or buffers
stream Stream<E> The broadcast stream
isClosed bool Whether the controller has been closed
close() Future<void> Closes the controller and clears the buffer

Buffering behavior:

Scenario Behavior
Effect emitted, no listener Queued in buffer
Listener subscribes Buffer flushed synchronously
Listener cancels (widget dispose) Subsequent effects buffered
New listener after cancel Buffer flushed again
add() after close() Throws StateError
Multiple listeners active All receive live events (broadcast)

Why broadcast + manual buffer?

A single-subscription StreamController buffers automatically but does NOT support re-subscription. Once the listener cancels (e.g. widget disposes during navigation), you cannot listen again. The broadcast approach handles re-subscription naturally.

Timeline:
  emitEffect(A)  →  emitEffect(B)  →  Widget subscribes  →  emitEffect(C)
  [buffered]         [buffered]        [flush A, B]          [delivered live]

  Widget disposes  →  emitEffect(D)  →  Widget remounts
  [cancel]            [buffered]        [flush D]

EffectObserver #

Global observability for all side effects across the app. Analogous to BlocObserver.

void main() {
  EffectObserver.instance = AppEffectObserver();
  runApp(MyApp());
}

class AppEffectObserver extends EffectObserver {
  @override
  void onEffect(BlocBase bloc, Object? effect) {
    debugPrint('[Effect] ${bloc.runtimeType} -> $effect');
  }
}

Use cases:

  • Development logging — print all effects to console
  • Analytics — track navigations, dialogs, user-facing actions
  • Crash reporting — attach recent effects as breadcrumbs (Sentry, Crashlytics)
  • Integration testing — assert global effect behavior
Member Type Description
onEffect(bloc, effect) void Called on every emitEffect across the app
EffectObserver.instance static EffectObserver? Global instance — set at app startup

Comparison with Alternatives #

Feature bloc_one_shot bloc_presentation bloc_one_shots side_effect_bloc
Buffering Yes No No No
Re-subscription safe Yes No No No
Global observer Yes No No No
Cubit support Yes Yes Yes No
Extra dependencies None nested None provider

License #

MIT — see LICENSE for details.

1
likes
150
points
182
downloads

Publisher

verified publisheraltumstack.com

Weekly Downloads

A side-effect system for Flutter BLoC. Provides a buffered EffectController, SideEffectMixin for Bloc/Cubit, and global EffectObserver for logging and analytics.

Homepage
Repository (GitHub)

Documentation

API reference

License

MIT (license)

Dependencies

bloc

More

Packages that depend on bloc_one_shot