Understanding Declarative Navigation in Flutter

Understanding Declarative Navigation in Flutter

What You’ll Learn

Learn how Flutter’s modern declarative navigation approach differs from imperative navigation (Navigator.push()). This pattern is essential for complex apps with deep linking, web URLs, and state-driven navigation.

Imperative vs Declarative: What’s the Difference?

Imperative (Navigator 1.0): You tell Flutter how to navigate

Navigator.push(context, MaterialPageRoute(builder: (_) => DetailsPage()));

Declarative (Navigator 2.0): You tell Flutter what to show based on app state

// Navigation reflects your app state
pages: [
  if (showHome) HomePage(),
  if (showDetails) DetailsPage(),
]

The declarative approach makes navigation predictable, testable, and works seamlessly with browser URLs and deep links.

Example: Simple Declarative Navigator

Here’s a practical example using Navigator.pages:

import 'package:flutter/material.dart';

// Our app's navigation state
class AppState extends ChangeNotifier {
  String? _selectedUserId;

  String? get selectedUserId => _selectedUserId;

  void selectUser(String id) {
    _selectedUserId = id;
    notifyListeners();
  }

  void clearSelection() {
    _selectedUserId = null;
    notifyListeners();
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => AppState(),
      child: MaterialApp(
        home: AppNavigator(),
      ),
    );
  }
}

class AppNavigator extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final appState = context.watch<AppState>();

    return Navigator(
      pages: [
        // Home page is always in the stack
        MaterialPage(
          key: ValueKey('home'),
          child: HomePage(),
        ),

        // Details page only shows when user is selected
        if (appState.selectedUserId != null)
          MaterialPage(
            key: ValueKey('details-${appState.selectedUserId}'),
            child: DetailsPage(userId: appState.selectedUserId!),
          ),
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }

        // Update state when user pops
        context.read<AppState>().clearSelection();
        return true;
      },
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Users')),
      body: ListView(
        children: [
          ListTile(
            title: Text('Alice'),
            onTap: () => context.read<AppState>().selectUser('alice'),
          ),
          ListTile(
            title: Text('Bob'),
            onTap: () => context.read<AppState>().selectUser('bob'),
          ),
        ],
      ),
    );
  }
}

class DetailsPage extends StatelessWidget {
  final String userId;

  const DetailsPage({required this.userId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('User: $userId')),
      body: Center(
        child: Text(
          'Details for $userId',
          style: TextStyle(fontSize: 24),
        ),
      ),
    );
  }
}

Key Concepts

1. Pages List: Defines the navigation stack based on state

2. onPopPage: Handle back button and pop gestures

3. MaterialPage vs Page

Why Use Declarative Navigation?

Predictable: Navigation is a pure function of state ✅ Testable: Easy to test navigation logic without widgets ✅ Deep linking: URLs map directly to state ✅ State restoration: Browser back button works automatically ✅ Single source of truth: Navigation state lives in one place

Practical Pattern: Router with State

For larger apps, use a router class:

class AppRouter extends ChangeNotifier {
  // Navigation state
  bool _showSplash = true;
  String? _selectedItemId;

  // Getters
  bool get showSplash => _showSplash;
  String? get selectedItemId => _selectedItemId;

  // Navigation actions
  void completedSplash() {
    _showSplash = false;
    notifyListeners();
  }

  void selectItem(String id) {
    _selectedItemId = id;
    notifyListeners();
  }

  void goBack() {
    _selectedItemId = null;
    notifyListeners();
  }

  // Generate pages based on current state
  List<Page> get pages => [
    if (_showSplash)
      SplashPage()
    else ...[
      HomePage(),
      if (_selectedItemId != null)
        DetailsPage(id: _selectedItemId!),
    ],
  ];

  // Handle system back button
  bool handlePopPage(Route route, dynamic result) {
    if (!route.didPop(result)) return false;

    if (_selectedItemId != null) {
      _selectedItemId = null;
      notifyListeners();
      return true;
    }

    return false;
  }
}

Try It Yourself

Build a shopping app with declarative navigation:

  1. Create a product list page (always visible)
  2. Add a product details page (shows when product selected)
  3. Add a cart page (shows when cart button tapped)
  4. Implement navigation state in a ChangeNotifier

Challenge: Add URL support so each page has a unique URL path (e.g., /products, /product/123, /cart). Use RouterDelegate and RouteInformationParser for full web URL support.

Tip of the Day

Migration Strategy: You don’t need to migrate everything at once. Declarative navigation can coexist with imperative Navigator.push() calls. Start with your main navigation flow and gradually migrate secondary flows.

go_router Package: For production apps, consider using the go_router package. It provides declarative routing with URL support, guards, and redirects—all built on Navigator 2.0 principles.