flutter
/

BLoC Side Effects: Navigation, Dialogs & One-Time Events

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Side Effects: Navigation, Dialogs & One-Time Events

What are Side Effects in BLoC?

In BLoC architecture, side effects are actions that should happen once in response to a state change, but are not part of the UI state itself. Examples include navigating to a new screen, showing a SnackBar or dialog, or playing a sound. Separating side effects from the UI rendering logic is crucial for maintainability, testability, and avoiding duplicate effects (like navigating twice).

Why Separate Side Effects?

  • Single Responsibility – BLoCs manage state; UI handles effects.
  • Avoid Duplicates – A single state change can trigger multiple rebuilds, which would cause multiple side effects if placed in build methods.
  • Testability – Side effects are easier to test when isolated.
  • Predictability – Effects happen exactly when you expect them, not on every rebuild.
  • Clean Code – Keeps UI widgets focused on rendering.

BlocListener: Listening for One-Time Events

BlocListener is a widget that listens to state changes and executes a callback once per state change. It does not rebuild its child, making it perfect for side effects like navigation or showing dialogs.

DARTRead-only
1
BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    if (state is AuthAuthenticated) {
      Navigator.pushReplacementNamed(context, '/home');
    } else if (state is AuthUnauthenticated) {
      Navigator.pushReplacementNamed(context, '/login');
    } else if (state is AuthError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
  },
  child: Container(), // or your main content
);

BlocConsumer: Combine Builder and Listener

When you need both to rebuild the UI and listen for side effects, use BlocConsumer. It combines the builder of BlocBuilder and the listener of BlocListener.

DARTRead-only
1
BlocConsumer<CartBloc, CartState>(
  listener: (context, state) {
    if (state is CartItemAdded) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Item added to cart')),
      );
    }
  },
  builder: (context, state) {
    if (state is CartLoading) {
      return CircularProgressIndicator();
    }
    if (state is CartLoaded) {
      return ListView.builder(...);
    }
    return Container();
  },
);

Conditional Listening with BlocListener

You can use listenWhen to conditionally trigger the listener based on the previous and current state. This prevents unnecessary effects.

DARTRead-only
1
BlocListener<AuthBloc, AuthState>(
  listenWhen: (previous, current) {
    // Only trigger when transitioning from not authenticated to authenticated
    return previous is AuthUnauthenticated && current is AuthAuthenticated;
  },
  listener: (context, state) {
    Navigator.pushReplacementNamed(context, '/home');
  },
  child: Container(),
);

Using BlocObserver for Global Side Effects

For global side effects like logging or analytics, you can use a custom BlocObserver. This intercepts all state changes and events across the app.

DARTRead-only
1
class AnalyticsObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    // Send analytics event for every state change
    analytics.logEvent(
      name: 'state_change',
      parameters: {
        'bloc': bloc.runtimeType.toString(),
        'state': change.nextState.toString(),
      },
    );
  }
}

void main() {
  Bloc.observer = AnalyticsObserver();
  runApp(MyApp());
}

Handling Navigation with BlocListener

Navigation is a common side effect. You can embed BlocListener at the root of your app to handle routing based on auth state, or inside a page to navigate after a successful action.

DARTRead-only
1
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<LoginBloc, LoginState>(
      listener: (context, state) {
        if (state is LoginSuccess) {
          Navigator.pushReplacementNamed(context, '/home');
        } else if (state is LoginFailure) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Login failed: ${state.error}')),
          );
        }
      },
      child: LoginForm(),
    );
  }
}

Avoiding Duplicate Effects with State Classes

Sometimes you need to perform an effect only once, but the state might be emitted multiple times (e.g., loading -> success -> success again). To avoid duplicate effects, you can use distinct states or a one-time flag. One pattern is to use a status field and a separate message field, and only show the message once.

DARTRead-only
1
class LoginState extends Equatable {
  final LoginStatus status;
  final String? errorMessage;
  const LoginState({this.status = LoginStatus.initial, this.errorMessage});
  
  @override List<Object?> get props => [status, errorMessage];
}

// In BlocListener:
listener: (context, state) {
  if (state.errorMessage != null) {
    // Show snackbar only once; after showing, you might clear the error
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(state.errorMessage!)),
    );
    // Optionally, dispatch an event to clear the error
    context.read<LoginBloc>().add(ClearError());
  }
}

Best Practices

  • Use BlocListener for side effects – Keep effects separate from UI building.
  • Use listenWhen to filter unnecessary calls – Prevent duplicate effects.
  • Place listeners high in the tree – For global effects like auth navigation, place at root.
  • Clear one-time messages after showing – Avoid showing the same snackbar on rebuilds.
  • Prefer navigation in listeners over event handlers – BLoCs should not know about Flutter's navigation API.
  • Test side effects separately – Use blocTest to verify that the right states are emitted; UI tests can verify that the listener triggers correctly.

Common Mistakes

  • ❌ Calling side effects directly in event handlers – The BLoC becomes coupled to Flutter's UI utilities.
  • ❌ Using BlocBuilder to handle side effects – The effect will run on every rebuild, causing duplicates.
  • ❌ Not using listenWhen for repeated states – Causes duplicate navigation or snackbars.
  • ❌ Placing BlocListener inside a BlocBuilder – The listener may be recreated on rebuilds; place it above.
  • ❌ Forgetting to handle error states – Errors may not be shown without a listener.
  • ❌ Mutating state inside the listener – Avoid emitting new states from the listener to prevent infinite loops.

Conclusion

Managing side effects in BLoC is straightforward with BlocListener and BlocConsumer. These widgets allow you to separate one-time actions from UI rendering, leading to cleaner, more predictable code. By following best practices, you can ensure that navigation, snackbars, and other effects happen exactly when intended.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/',
      routes: {
        '/': (context) => LoginPage(),
        '/home': (context) => HomePage(),
      },
    );
  }
}

// ---------- Login BLoC ----------
class LoginState extends Equatable {
  final bool isLoading;
  final String? errorMessage;
  final bool success;

  const LoginState({
    this.isLoading = false,
    this.errorMessage,
    this.success = false,
  });

  LoginState copyWith({
    bool? isLoading,
    String? errorMessage,
    bool? success,
  }) {
    return LoginState(
      isLoading: isLoading ?? this.isLoading,
      errorMessage: errorMessage ?? this.errorMessage,
      success: success ?? this.success,
    );
  }

  @override
  List<Object?> get props => [isLoading, errorMessage, success];
}

abstract class LoginEvent extends Equatable {
  const LoginEvent();
  @override List<Object?> get props => [];
}

class LoginSubmitted extends LoginEvent {
  final String email;
  final String password;
  const LoginSubmitted(this.email, this.password);
  @override List<Object?> get props => [email, password];
}

class ClearError extends LoginEvent {}

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  LoginBloc() : super(const LoginState()) {
    on<LoginSubmitted>(_onLoginSubmitted);
    on<ClearError>((event, emit) => emit(state.copyWith(errorMessage: null)));
  }

  Future<void> _onLoginSubmitted(LoginSubmitted event, Emitter<LoginState> emit) async {
    emit(state.copyWith(isLoading: true, errorMessage: null));
    await Future.delayed(Duration(seconds: 1));
    if (event.email == 'test@example.com' && event.password == 'password') {
      emit(state.copyWith(isLoading: false, success: true, errorMessage: null));
    } else {
      emit(state.copyWith(
        isLoading: false,
        errorMessage: 'Invalid credentials',
        success: false,
      ));
    }
  }
}

// ---------- UI ----------
class LoginPage extends StatelessWidget {
  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => LoginBloc(),
      child: Scaffold(
        appBar: AppBar(title: Text('Login Side Effects')),
        body: BlocListener<LoginBloc, LoginState>(
          listener: (context, state) {
            if (state.success) {
              Navigator.pushReplacementNamed(context, '/home');
            }
            if (state.errorMessage != null) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(state.errorMessage!)),
              );
              // Clear the error so it doesn't show again
              context.read<LoginBloc>().add(ClearError());
            }
          },
          child: BlocBuilder<LoginBloc, LoginState>(
            builder: (context, state) {
              return Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    TextField(
                      controller: emailController,
                      decoration: InputDecoration(labelText: 'Email'),
                    ),
                    TextField(
                      controller: passwordController,
                      obscureText: true,
                      decoration: InputDecoration(labelText: 'Password'),
                    ),
                    SizedBox(height: 20),
                    if (state.isLoading)
                      CircularProgressIndicator()
                    else
                      ElevatedButton(
                        onPressed: () {
                          context.read<LoginBloc>().add(LoginSubmitted(
                            emailController.text,
                            passwordController.text,
                          ));
                        },
                        child: Text('Login'),
                      ),
                    SizedBox(height: 20),
                    Text('Demo: test@example.com / password'),
                  ],
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('You are logged in!'),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () => Navigator.pushReplacementNamed(context, '/'),
              child: Text('Logout'),
            ),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

Which widget should you use to perform side effects like navigation or showing snackbars in response to state changes?

A
BlocBuilder
B
BlocListener
C
BlocProvider
D
BlocConsumer
Q2
of 4

What parameter can you use to conditionally trigger the listener based on previous and current state?

A
condition
B
listenWhen
C
filter
D
buildWhen
Q3
of 4

Why should you avoid calling `Navigator.push` directly inside a BLoC's event handler?

A
It's not allowed
B
It couples the BLoC to Flutter's navigation system
C
It causes memory leaks
D
It's slower
Q4
of 4

What is the purpose of `BlocConsumer`?

A
To consume the bloc and dispose it
B
To combine a builder (for UI) and a listener (for side effects)
C
To listen to multiple blocs
D
To create a new bloc

Frequently Asked Questions

Can I use `BlocListener` without a child widget?

No, BlocListener requires a child. You can pass SizedBox.shrink() or Container() as a placeholder if you don't need to render anything.

What's the difference between `BlocListener` and `BlocBuilder`?

BlocBuilder rebuilds its child on every state change, while BlocListener only executes a callback and does not rebuild its child. Use them together when you need both.

How do I test that a navigation occurred from a BLoC state?

In widget tests, you can mock the Navigator and verify that push or pushReplacement was called. Alternatively, use blocTest to verify that the correct state was emitted, and rely on integration tests for full navigation flows.

Can I have multiple `BlocListener` widgets for the same bloc?

Yes, you can have multiple listeners. Each will be called when the state changes. This is useful for separating different concerns (e.g., one for navigation, one for analytics).

How do I handle side effects that depend on the previous state?

Use listenWhen to compare previous and current. You can also store previous values in the state or use external variables.

Previous

bloc split widgets

Next

bloc deeplink navigation

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.