UI Side Effects
In a clean architecture, certain events are meant to trigger one-shot actions in the UI, rather than persisting in the screen's state. Examples include showing a snackbar, displaying a toast notification, popping up a dialog, or navigating to another screen.
If you store these events in your view state (e.g., setting hasError: true to show a snackbar), you have to write complex "state reset" logic after the snackbar dismisses. If the screen rotates, the snackbar might show again.
VMResultEffect solves this by providing a clean, stream-based side-effect channel alongside your normal view state.
1. Defining the Effects
All effects must inherit from the BaseUiEffect contract class. You can define your screen's effects using a sealed class:
import 'package:vm_result/vm_result.dart';
sealed class CheckoutEffect extends BaseUiEffect {
const CheckoutEffect();
}
class ShowSuccessToast extends CheckoutEffect {
const ShowSuccessToast(this.message);
final String message;
}
class NavigateToReceipt extends CheckoutEffect {
const NavigateToReceipt(this.orderId);
final String orderId;
}
2. Emitting Effects in the ViewModel
To emit effects, extend VMResultEffect<S, UE> instead of VMResult<S>:
class CheckoutViewModel extends VMResultEffect<CheckoutState, CheckoutEffect> {
CheckoutViewModel(this._repository) : super(const Result.initial());
final CheckoutRepository _repository;
Future<void> submitPayment() async {
final outcome = await runWithValueResult(() => _repository.checkout());
outcome.when(
success: (receipt) {
// Emit side effect for navigation
emitEffect(NavigateToReceipt(receipt.id));
},
failure: (error) {
// Emit side effect for notification
emitEffect(ShowSuccessToast('Payment failed: ${error.toString()}'));
},
);
}
}
- Safety: Effects emitted after the ViewModel is disposed are silently dropped to avoid exceptions, and warnings are printed in debug mode.
3. Listening to Effects in the UI
To receive side-effects, wrap your widget subtree with the EffectListener widget.
import 'package:flutter/material.dart';
import 'package:vm_result/vm_result.dart';
import 'checkout_view_model.dart';
class CheckoutScreen extends StatefulWidget {
const CheckoutScreen({super.key});
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
late final CheckoutViewModel _viewModel;
void initState() {
super.initState();
_viewModel = CheckoutViewModel(CheckoutRepository());
}
void dispose() {
_viewModel.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Checkout')),
// Wrap your screen structure with the EffectListener
body: EffectListener<CheckoutViewModel, CheckoutState, CheckoutEffect>(
vm: _viewModel,
listener: (context, effect) {
// React to one-shot actions cleanly
switch (effect) {
case ShowSuccessToast(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
case NavigateToReceipt(:final orderId):
Navigator.of(context).pushReplacementNamed(
'/receipt',
arguments: orderId,
);
}
},
child: Center(
child: ElevatedButton(
onPressed: _viewModel.submitPayment,
child: const Text('Submit Payment'),
),
),
),
);
}
}
Why use EffectListener?
- Lifecycle Bound: It automatically handles stream subscription lifecycle hooks, subscribing in
initStateand canceling indisposeto prevent memory leaks. - Dynamic Swapping: It implements
didUpdateWidget. If your widget structure rebuilds and swaps the ViewModel instance,EffectListenerautomatically unsubscribes from the old stream and hooks into the new one.