Sparround

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:

  • BLoCBlocListener (or the listener half of BlocConsumer), filtered with listenWhen.
  • Riverpodref.listen (safe inside build), and ref.listenManual outside build.
  • Provider — there is no dedicated listener API: you register ChangeNotifier's addListener by hand in initState/didChangeDependencies (and remove it in dispose), or perform the side effect directly in the callback using context.read.
dart
// 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 mounted check on a State; BuildContext itself also exposes a mounted property.
  • Dart's use_build_context_synchronously lint 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/next comparison 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 effectThe right placeThe typical mistake
Navigation`BlocListener` / `ref.listen` / a callback`if (state.success) Navigator...` in `build` — navigating twice
Snackbar / dialogA listener; only on a new error via `listenWhen`Showing it again on every rebuild
An analytics eventA listener, or the notifier/bloc's own methodSending it from `build` → inflated metrics
Using `context` after an `await`A `mounted` check; the `use_build_context_synchronously` lintUsing 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