bloc_one_shot 0.1.0
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 #
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), seeflutter_bloc_one_shot.For test utilities (
blocEffectTest), seebloc_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.