Side effects: navigation, dialogs, snackbars
The most commonly mishandled part of state management is work that must happen exactly once: navigation, a dialog, a snackbar, an analytics event, sharing a file.
The problem comes from the nature of build: it can be called again at any moment (a parent rebuilt, the keyboard opened, the screen rotated). Put a side effect inside build and it repeats too — two navigations, two overlapping dialogs, or a duplicated analytics event.
Each library has a designated place for this:
- BLoC —
BlocListener(or thelistenerhalf ofBlocConsumer), filtered withlistenWhen. - Riverpod —
ref.listen(safe insidebuild), andref.listenManualoutsidebuild. - Provider — there is no dedicated listener API: you register
ChangeNotifier'saddListenerby hand ininitState/didChangeDependencies(and remove it indispose), or perform the side effect directly in the callback usingcontext.read.
// BLoC: listener yan effekt üçün ayrılmış yerdir.
BlocListener<LoginBloc, LoginState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == LoginStatus.success) {
Navigator.of(context).pushReplacementNamed('/home');
}
},
child: const LoginForm(),
);
// Riverpod: ref.listen build içində təhlükəsizdir.
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(loginProvider, (previous, next) {
if (next.isLoggedIn && !(previous?.isLoggedIn ?? false)) {
Navigator.of(context).pushReplacementNamed('/home');
}
});
return const LoginForm();
}
}
// Provider: listener əl ilə qeyd olunur və mütləq silinir.
class _LoginPageState extends State<LoginPage> {
LoginModel? _model;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final model = context.read<LoginModel>();
if (model != _model) {
_model?.removeListener(_onModelChanged);
_model = model..addListener(_onModelChanged);
}
}
void _onModelChanged() {
if (!mounted) return; // async bildirişdən sonra qorunma
if (_model!.isLoggedIn) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
@override
void dispose() {
_model?.removeListener(_onModelChanged);
super.dispose();
}
@override
Widget build(BuildContext context) => const LoginForm();
}A navigation that must happen once — three libraries, three correct places.
`context` after an `await`. When async work finishes, the widget may no longer be in the tree. Calling Navigator.of(context) or ScaffoldMessenger.of(context) then leads to an error.
There are two guards:
- The
mountedcheck on aState;BuildContextitself also exposes amountedproperty. - Dart's
use_build_context_synchronouslylint rule catches this at static-analysis level — keeping it enabled lowers review overhead for the team.
Storing a one-shot effect in state. If an event like "navigate" is kept as part of the state (shouldNavigate: true), it must be cleared after use, otherwise returning to the screen repeats the effect. Three practical options:
- Derive the effect in the listener from a
previous/nextcomparison rather than storing it (the simplest and most widely used). - Reset the one-shot field after consuming it (
emit(state.copyWith(navigateTo: null))). - Model effects as a separate stream (a stream of alerts, say) that the UI listens to.
| Side effect | The right place | The typical mistake |
|---|---|---|
| Navigation | `BlocListener` / `ref.listen` / a callback | `if (state.success) Navigator...` in `build` — navigating twice |
| Snackbar / dialog | A listener; only on a new error via `listenWhen` | Showing it again on every rebuild |
| An analytics event | A listener, or the notifier/bloc's own method | Sending it from `build` → inflated metrics |
| Using `context` after an `await` | A `mounted` check; the `use_build_context_synchronously` lint | Using it unchecked → an error on a disposed widget |
The Riverpod docs address this with a rule of their own: don't perform side effects during provider initialisation — a provider represents a read operation, and using one for "write" operations such as submitting a form is wrong (the docs point to the experimental Mutation mechanism as the alternative). The practical takeaway: keep side effects in notifier methods and UI listeners, not in a provider's body.
📚 Sources and documentation
- The use_build_context_synchronously lint ruleofficialdart.dev
The official explanation of why using context after an await is a problem.
- BuildContext.mountedofficialapi.flutter.dev
- Riverpod: DO / DON'Tofficialriverpod.dev
The prohibition on side effects during provider initialisation.
- Flutter Bloc: BlocListenerofficialbloclibrary.dev
The official description of BlocListener and listenWhen.