flutter
/

Bloc Navigation: Handling Navigation with BLoC Pattern

Last Sync: Today

On this page

10
0%
Intermediate
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutterIntermediate

Bloc Navigation: Handling Navigation with BLoC Pattern

Navigation is a core part of any mobile app. In a clean BLoC architecture, navigation should be treated as a side effect of state changes. You never call Navigator directly inside a bloc; instead, you emit a state that the UI listens to, and the UI (or a dedicated navigation observer) handles the actual navigation. This keeps your business logic pure and testable.

Why Navigation in Bloc Should Be a Side Effect

  • Separation of concerns – Bloc should only emit states; navigation is a UI concern.
  • Testability – A bloc that emits a NavigateToHome state is easy to test; you don’t need to mock Navigator.
  • Reusability – The same bloc can be used with different navigation systems (e.g., different routers).
  • Predictability – All state changes are explicit; navigation becomes a response to a state change.

Approach 1: Using BlocListener for Navigation

The most common and recommended way is to have your bloc emit a navigation state (e.g., LoginSuccess, NavigateToProfile), and use a BlocListener (or BlocConsumer) to perform the actual navigation when that state is emitted.

DARTRead-only
1
// States
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState {
  final String userId;
  AuthSuccess(this.userId);
}
class AuthFailure extends AuthState {
  final String message;
  AuthFailure(this.message);
}

// Bloc
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(AuthInitial()) {
    on<LoginSubmitted>(_onLoginSubmitted);
  }

  Future<void> _onLoginSubmitted(LoginSubmitted event, Emitter<AuthState> emit) async {
    emit(AuthLoading());
    try {
      final userId = await repository.login(event.email, event.password);
      emit(AuthSuccess(userId)); // <- navigation state
    } catch (e) {
      emit(AuthFailure(e.toString()));
    }
  }
}
DARTRead-only
1
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state is AuthSuccess) {
          Navigator.pushReplacementNamed(
            context,
            '/home',
            arguments: state.userId,
          );
        }
        if (state is AuthFailure) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(state.message)),
          );
        }
      },
      child: LoginForm(),
    );
  }
}

Approach 2: Using a Custom Navigation Service

For larger apps, you may want to centralize navigation logic. Create a navigation service that the UI can call. The bloc still emits states, but the listener calls the service.

DARTRead-only
1
class NavigationService {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  Future<T?> pushNamed<T>(String routeName, {Object? arguments}) {
    return navigatorKey.currentState!.pushNamed<T>(
      routeName,
      arguments: arguments,
    );
  }

  void pop<T>([T? result]) {
    return navigatorKey.currentState!.pop<T>(result);
  }

  void pushReplacementNamed(String routeName, {Object? arguments}) {
    navigatorKey.currentState!.pushReplacementNamed(routeName, arguments: arguments);
  }
}

// In main.dart
final navigationService = NavigationService();

MaterialApp(
  navigatorKey: navigationService.navigatorKey,
  // ...
);
DARTRead-only
1
// In listener
if (state is AuthSuccess) {
  navigationService.pushReplacementNamed('/home', arguments: state.userId);
}

Approach 3: Passing a Callback to the Bloc (Not Recommended)

Some developers inject a navigation callback into the bloc. This is generally discouraged because it mixes UI concerns into the business logic and makes testing harder. However, it can be used in very simple scenarios.

Passing Arguments to Navigation

When navigating, you often need to pass data to the next screen. With BLoC, you can include the data in the state (e.g., AuthSuccess contains the userId). The listener then passes that data as arguments when pushing the route.

DARTRead-only
1
// In listener
if (state is AuthSuccess) {
  Navigator.pushNamed(
    context,
    '/profile',
    arguments: state.userId,
  );
}

// In the profile screen
final userId = ModalRoute.of(context)!.settings.arguments as String;

Deep Linking and Initial Routes

For deep linking, you may need to decide the initial route based on authentication state. This can be handled by using a SplashScreen that checks the auth state and then conditionally navigates. Or you can set the initial route in MaterialApp based on a synchronous check of persisted auth data.

DARTRead-only
1
class SplashPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state is AuthSuccess) {
          Navigator.pushReplacementNamed(context, '/home');
        } else if (state is AuthInitial || state is AuthFailure) {
          Navigator.pushReplacementNamed(context, '/login');
        }
      },
      child: Scaffold(body: Center(child: CircularProgressIndicator())),
    );
  }
}

Testing Navigation in Bloc

Since navigation is a side effect, you don't test the actual Navigator calls in unit tests. Instead, you test that the bloc emits the expected states. The UI tests (widget tests) can verify that the BlocListener correctly triggers navigation.

DARTRead-only
1
test('emits AuthSuccess on successful login', () {
  when(repository.login(any, any)).thenAnswer((_) async => 'user123');

  final bloc = AuthBloc(repository);
  bloc.add(LoginSubmitted('test@test.com', 'pass'));

  expectLater(
    bloc.stream,
    emitsInOrder([
      isA<AuthLoading>(),
      isA<AuthSuccess>().having((s) => s.userId, 'userId', 'user123'),
    ]),
  );
});

Best Practices

  • Use BlocListener for navigation – It keeps navigation code separate from UI rebuilding.
  • Emit navigation states – Instead of calling Navigator directly from the bloc, emit a state like NavigateToHome.
  • Keep navigation logic in the UI layer – The bloc should not know about Navigator.
  • Use a navigation service for complex routing – Centralizes navigation and makes it easier to manage.
  • Pass arguments via state or route arguments – Include data in the state, then pass as arguments when navigating.
  • Test only state emissions – Unit tests for blocs should not depend on Navigator; verify the emitted states.

Common Mistakes

  • ❌ Calling Navigator inside the bloc – Breaks separation of concerns and makes testing difficult. ✅ Emit a navigation state and let the UI handle it.
  • ❌ Using BlocBuilder for navigation – Can cause multiple navigations if the state is emitted multiple times. ✅ Use BlocListener (only runs once per state).
  • ❌ Not handling state transitions correctly – For example, navigating on every AuthSuccess emission even if already on the target screen. ✅ Use conditional navigation or a listenWhen to avoid duplicate pushes.
  • ❌ Forgetting to dispose BlocListener – Not an issue; BlocListener automatically manages its subscription.
  • ❌ Passing navigation callbacks to the bloc – Creates tight coupling. ✅ Use the state‑driven approach.

Conclusion

Navigation in BLoC should be treated as a side effect of state changes. By using BlocListener and emitting navigation‑related states, you keep your business logic pure, your UI reactive, and your code testable. Whether you use a simple BlocListener or a full navigation service, the key is to separate the decision to navigate (in the bloc) from the actual navigation (in the UI).

Test Your Knowledge

Q1
of 3

What is the recommended way to trigger navigation from a bloc?

A
Call Navigator directly in the bloc
B
Emit a navigation state and use BlocListener
C
Pass a navigation callback to the bloc
D
Use setState in the UI
Q2
of 3

Which Bloc widget should you use for navigation side effects?

A
BlocBuilder
B
BlocProvider
C
BlocListener
D
BlocConsumer
Q3
of 3

Why should navigation logic not be inside the bloc?

A
It makes the bloc larger
B
It couples the bloc to the UI framework and makes testing difficult
C
It's not supported by flutter_bloc
D
It causes runtime errors

Frequently Asked Questions

Can I navigate from a bloc using a callback?

Technically yes, but it's not recommended. Injecting a navigation callback couples the bloc to the UI framework and makes testing harder. Prefer emitting navigation states and using BlocListener.

How do I navigate with arguments using Bloc?

Include the data in your navigation state (e.g., NavigateToProfile with a userId). Then in the BlocListener, pass that data as arguments when calling Navigator.pushNamed.

What is the difference between `BlocListener` and `BlocBuilder` for navigation?

BlocListener is designed for side effects like navigation and runs only once per state emission. BlocBuilder rebuilds the UI and would cause multiple navigations if used incorrectly. Always use BlocListener for navigation.

How do I handle deep linking with Bloc?

You can use a SplashScreen that checks authentication state via a bloc and then conditionally navigates to the appropriate initial route. Alternatively, use a routing package like go_router with a bloc that provides the auth state.

Should I use a navigation service or a BlocListener?

Both work. For simple apps, BlocListener is enough. For large apps with complex routing, a navigation service (e.g., using a GlobalKey<NavigatorState>) helps centralize navigation logic and makes it easier to test.

Previous

bloc context read watch

Next

bloc route management

Related Content

Need help?

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