Avoid navigation in BLoC and keep routing in the UI layer

Summary

The architectural debate often arises when transitioning from reactive state management (like Riverpod) to event-driven state management (like BLoC). The core issue is a misunderstanding of Separation of Concerns: developers often struggle to decide whether navigation logic belongs inside the BLoC (the business logic layer) or within the UI (the presentation layer). Using BLoC to trigger navigation directly is a common anti-pattern that leads to tightly coupled code and difficulty in testing.

Root Cause

The confusion stems from the “imperative” vs “declarative” mental models:

  • Tight Coupling: Attempting to pass a BuildContext into a BLoC to call Navigator.of(context) breaks the rule that business logic should be platform-independent and UI-agnostic.
  • Side-Effect Mismanagement: Navigation is a side effect of a state change, not a state itself.
  • State vs. Action: Developers mistake a “Navigation Command” for a “State.” In BLoC, you emit states; you do not “execute” actions like routing.

Why This Happens in Real Systems

In large-scale production systems, the goal is to make the business logic testable in a headless environment (without a device or emulator).

  • Dependency Injection Bloat: If BLoCs require BuildContext or NavigatorState, you cannot run unit tests for your logic without mocking the entire Flutter framework.
  • Testing Complexity: To test if a user is redirected after a failed login, you shouldn’t have to simulate a widget tree; you should only have to assert that a specific FailureState was emitted.
  • Context Invalidity: In asynchronous operations, by the time a BLoC finishes a task and tries to use a passed BuildContext, that context might no longer be mounted, leading to the infamous “Looking up a deactivated widget’s ancestor” error.

Real-World Impact

  • Brittle Test Suites: Tests become flaky and slow because they require integration tests instead of lightweight unit tests.
  • Memory Leaks: Holding onto BuildContext or Navigator references inside long-lived BLoCs can prevent the garbage collector from cleaning up disposed views.
  • Rigid Architecture: If you decide to switch from Navigator 1.0 to GoRouter, you are forced to rewrite your business logic layer instead of just updating your UI routing layer.

Example or Code

// THE WRONG WAY: Passing context to BLoC
class LoginBloc extends Bloc {
  void onLoginSubmitted(LoginEvent event, Emitter emit) {
    // ERROR: Business logic should NOT know about BuildContext
    // Navigator.of(context).pushNamed('/home'); 
  }
}

// THE RIGHT WAY: Using BlocListener in the UI
class LoginScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener(
      listener: (context, state) {
        if (state is LoginSuccess) {
          context.go('/home'); // GoRouter or Navigator call happens in UI
        } else if (state is LoginFailure) {
          ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error')));
        }
      },
      child: LoginForm(),
    );
  }
}

How Senior Engineers Fix It

Senior engineers implement a Unidirectional Data Flow where navigation is treated as a reaction to a state change.

  • BlocListener: Use the BlocListener widget to listen for specific state changes (e.g., AuthenticatedState) and trigger imperative navigation (GoRouter or Navigator) within the UI layer.
  • Stream-based Navigation: For highly complex flows, they may use a dedicated NavigationService or a StreamController that the BLoC interacts with via an interface, keeping the BLoC unaware of the Flutter framework.
  • State-Driven Routing: They define the “Navigation State” as part of the business logic, but the “Execution of Navigation” remains a UI concern.

Why Juniors Miss It

  • The “GetX” Trap: Many beginners come from frameworks like GetX where Get.to() can be called anywhere. This feels “convenient” but destroys architectural boundaries.
  • Focus on Functionality over Maintainability: A junior focuses on “making the screen change,” while a senior focuses on “making the logic testable and decoupled.”
  • Ignoring the Lifecycle: Juniors often overlook that BuildContext is transient and tied to a specific location in the widget tree, whereas a BLoC is often a long-lived object.

Leave a Comment