flutter_bloc_one_shot 0.2.0
flutter_bloc_one_shot: ^0.2.0 copied to clipboard
Flutter widgets for bloc_one_shot. Provides SideEffectProvider, SideEffectListener, and SideEffectConsumer widgets that mirror BlocProvider/BlocListener/BlocConsumer API conventions.
flutter_bloc_one_shot #
Flutter widgets for bloc_one_shot. Provides SideEffectProvider, SideEffectListener, SideEffectConsumer, and MultipleSideEffectListener that mirror the BlocProvider/BlocListener/BlocConsumer/MultiBlocListener API you already know.
For test utilities (
blocEffectTest), seebloc_one_shot_test.
Installation #
dependencies:
flutter_bloc_one_shot: ^0.2.0
This package re-exports bloc_one_shot, so you only need a single dependency for both core and widgets.
Quick Start #
1. Define effects and add the mixin #
// Effects — ephemeral, one-shot actions
sealed class LoginEffect {}
class NavigateToHome extends LoginEffect {}
class ShowErrorSnackbar extends LoginEffect {
final String message;
ShowErrorSnackbar(this.message);
}
// Cubit with SideEffectMixin
class LoginCubit extends Cubit<LoginState>
with SideEffectMixin<LoginState, LoginEffect> {
Future<void> login(String email, String password) async {
emit(LoginLoading());
try {
await authRepo.login(email, password);
emit(LoginSuccess());
emitEffect(NavigateToHome()); // Side effect
} catch (e) {
emit(LoginInitial());
emitEffect(ShowErrorSnackbar(e.toString()));
}
}
}
2. Use widgets in the UI #
See SideEffectListener and SideEffectConsumer below.
Widgets #
SideEffectListener #
Listens to side effects without rebuilding the widget tree. Use this for navigation, snackbars, dialogs, and other fire-and-forget actions.
SideEffectListener<LoginCubit, LoginEffect>(
listener: (context, effect) {
switch (effect) {
case NavigateToHome():
Navigator.of(context).pushReplacementNamed('/home');
case ShowErrorSnackbar(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
child: LoginForm(),
)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
listener |
void Function(BuildContext, E) |
Yes | Called once per effect |
bloc |
B? |
No | If omitted, resolved via context.read<B>() |
listenWhen |
bool Function(E)? |
No | Filter — listener only fires when this returns true |
child |
Widget? |
No | Child widget |
Bloc resolution
If bloc is not provided, SideEffectListener resolves it from the widget tree using context.read<B>(). This means you can provide your Bloc via BlocProvider as usual:
BlocProvider(
create: (_) => LoginCubit(),
child: SideEffectListener<LoginCubit, LoginEffect>(
listener: (context, effect) { /* ... */ },
child: LoginForm(),
),
)
Filtering effects
Use listenWhen to filter which effects trigger the listener:
SideEffectListener<LoginCubit, LoginEffect>(
listenWhen: (effect) => effect is NavigateToHome,
listener: (context, effect) {
// Only called for NavigateToHome effects
Navigator.of(context).pushReplacementNamed('/home');
},
child: LoginForm(),
)
Note: Unlike
BlocListener's condition which receives(previous, current),listenWhentakes a single effect. Effects are ephemeral — there is no "previous" effect.
Buffered delivery
Effects emitted before the widget subscribes (e.g. during Bloc initialization) are buffered and delivered when the listener mounts:
// Effect emitted before widget tree is built
final cubit = LoginCubit();
cubit.emitEffect(ShowErrorSnackbar('Session expired'));
// Later, when the widget mounts, it receives the buffered effect
SideEffectListener<LoginCubit, LoginEffect>(
bloc: cubit,
listener: (context, effect) {
// 'Session expired' is delivered here
},
child: LoginForm(),
)
Re-subscription safety
When a widget unmounts (e.g. during navigation) and remounts later, any effects emitted during the gap are buffered and delivered to the new listener:
Widget mounts → effect A (delivered) → Widget unmounts
Widget remounts → effect B (was buffered, now delivered) → effect C (delivered live)
SideEffectProvider #
Combines BlocProvider and SideEffectListener into a single widget. Use this when you need to create (or provide) a Bloc and listen to its effects in one step.
SideEffectProvider<LoginCubit, LoginEffect>(
create: (_) => LoginCubit(),
listener: (context, effect) {
switch (effect) {
case NavigateToHome():
Navigator.of(context).pushReplacementNamed('/home');
case ShowErrorSnackbar(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
child: LoginForm(),
)
This is equivalent to:
BlocProvider(
create: (_) => LoginCubit(),
child: SideEffectListener<LoginCubit, LoginEffect>(
listener: (context, effect) { /* ... */ },
child: LoginForm(),
),
)
.value constructor
Use SideEffectProvider.value to provide an existing Bloc instance (without closing it on dispose):
SideEffectProvider<LoginCubit, LoginEffect>.value(
value: existingCubit,
listener: (context, effect) { /* ... */ },
child: LoginForm(),
)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
create |
B Function(BuildContext) |
Yes* | Creates the Bloc (*default constructor only) |
value |
B |
Yes* | Existing Bloc instance (*.value constructor only) |
listener |
void Function(BuildContext, E) |
Yes | Called once per effect |
listenWhen |
bool Function(E)? |
No | Filter — listener only fires when this returns true |
lazy |
bool |
No | Whether to lazily create the Bloc (default: true) |
child |
Widget? |
No | Child widget |
MultipleSideEffectListener #
Merges multiple SideEffectListener widgets into a single widget tree, avoiding deeply nested listeners. Mirrors MultiBlocListener from flutter_bloc.
MultipleSideEffectListener(
listeners: [
SideEffectListener<AuthBloc, AuthEffect>(
listener: (context, effect) {
// Handle auth effects (e.g. session expired → navigate to login)
},
),
SideEffectListener<NotificationBloc, NotificationEffect>(
listener: (context, effect) {
// Handle notification effects (e.g. show snackbar)
},
),
],
child: HomePage(),
)
This is equivalent to nesting them manually:
// Without MultipleSideEffectListener (nesting hell)
SideEffectListener<AuthBloc, AuthEffect>(
listener: (context, effect) { /* ... */ },
child: SideEffectListener<NotificationBloc, NotificationEffect>(
listener: (context, effect) { /* ... */ },
child: HomePage(),
),
)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
listeners |
List<SingleChildWidget> |
Yes | List of SideEffectListener widgets |
child |
Widget |
Yes | Child widget rendered below all listeners |
All SideEffectListener features (listenWhen, bloc, context resolution) work inside MultipleSideEffectListener.
SideEffectConsumer #
Combines BlocBuilder (for state) and SideEffectListener (for effects) in a single widget. Equivalent to nesting a SideEffectListener around a BlocBuilder.
SideEffectConsumer<LoginCubit, LoginState, LoginEffect>(
builder: (context, state) {
return switch (state) {
LoginLoading() => const Center(child: CircularProgressIndicator()),
LoginSuccess() => const Center(child: Text('Welcome!')),
_ => LoginForm(),
};
},
listener: (context, effect) {
switch (effect) {
case NavigateToHome():
Navigator.of(context).pushReplacementNamed('/home');
case ShowErrorSnackbar(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
builder |
Widget Function(BuildContext, S) |
Yes | Builds UI from state |
listener |
void Function(BuildContext, E) |
Yes | Called once per effect |
bloc |
B? |
No | If omitted, resolved via context.read<B>() |
buildWhen |
bool Function(S, S)? |
No | State filter — same as BlocBuilder.buildWhen |
listenWhen |
bool Function(E)? |
No | Effect filter |
Independent filters
buildWhen and listenWhen operate independently — you can filter state rebuilds without affecting effect delivery, and vice versa:
SideEffectConsumer<CounterCubit, int, CounterEffect>(
// Only rebuild when count is even
buildWhen: (previous, current) => current.isEven,
// Only listen to overflow effects
listenWhen: (effect) => effect is ShowOverflow,
builder: (context, count) => Text('$count'),
listener: (context, effect) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Counter overflow!')),
);
},
)
When to Use What #
| Scenario | Widget |
|---|---|
| React to effects only (navigation, snackbar) | SideEffectListener |
| Create/provide a Bloc + listen to effects | SideEffectProvider |
| React to effects from multiple blocs | MultipleSideEffectListener |
| Build UI from state only | BlocBuilder (from flutter_bloc) |
| Build UI from state + react to effects | SideEffectConsumer |
| Listen to state changes (not effects) | BlocListener (from flutter_bloc) |
Global Effect Observer #
Set up a global observer at app startup to log, track, or report all effects:
void main() {
EffectObserver.instance = AppEffectObserver();
runApp(MyApp());
}
class AppEffectObserver extends EffectObserver {
@override
void onEffect(BlocBase bloc, Object? effect) {
debugPrint('[Effect] ${bloc.runtimeType} -> $effect');
// Also: send to analytics, attach as Sentry breadcrumb, etc.
}
}
Full Example #
// main.dart
void main() {
EffectObserver.instance = LoggingEffectObserver();
runApp(
MaterialApp(
home: BlocProvider(
create: (_) => LoginCubit(),
child: const LoginPage(),
),
),
);
}
// login_page.dart
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SideEffectConsumer<LoginCubit, LoginState, LoginEffect>(
listener: (context, effect) {
switch (effect) {
case NavigateToHome():
Navigator.of(context).pushReplacementNamed('/home');
case ShowErrorSnackbar(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
builder: (context, state) {
return switch (state) {
LoginLoading() => const Center(
child: CircularProgressIndicator(),
),
_ => LoginForm(
onSubmit: (email, password) {
context.read<LoginCubit>().login(email, password);
},
),
};
},
),
);
}
}
License #
MIT — see LICENSE for details.