Navigation & Routing

Passing Data Between Screens

15 min Lesson 2 of 14

Passing Data Between Screens

Nearly every real-world Flutter app needs to pass information from one screen to another. A product listing page must send a product ID to the detail page; a login form must hand a user token to the home screen; a settings screen must return the user’s chosen theme back to the caller. Flutter gives you two clean mechanisms for this: passing data forward via constructor arguments when you push a route, and returning data backward via Navigator.pop() when you pop a route.

Note: This lesson covers the imperative Navigator API (Navigator 1.0). The same data-passing concepts apply to the declarative Router/GoRouter API, which is covered in a later lesson.

Passing Data Forward to a New Screen

When you push a new route with Navigator.push(), you create an instance of the destination widget directly. The cleanest way to send data is to add required constructor parameters to that widget. The caller fills them in at push time, and the destination reads them from widget.fieldName (in a StatefulWidget) or directly from constructor fields (in a StatelessWidget).

Example: Passing a Product to a Detail Screen

// 1. Define a simple data class
class Product {
  final int id;
  final String name;
  final double price;

  const Product({required this.id, required this.name, required this.price});
}

// 2. The destination screen accepts data via constructor
class ProductDetailScreen extends StatelessWidget {
  final Product product;

  const ProductDetailScreen({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('ID: ${product.id}'),
            Text('Price: \$${product.price.toStringAsFixed(2)}'),
          ],
        ),
      ),
    );
  }
}

// 3. The caller pushes the route and passes data
void _openProduct(BuildContext context, Product product) {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (context) => ProductDetailScreen(product: product),
    ),
  );
}

This approach is type-safe: the Dart compiler enforces that all required fields are provided. There is no stringly-typed key lookup, no casting, and no risk of passing the wrong type.

Tip: Keep your data models as plain Dart classes (or freezed / equatable classes). Avoid passing raw Map<String, dynamic> between screens — it loses type safety and becomes hard to refactor as the data shape grows.

Returning Data Back to the Calling Screen

Sometimes the new screen collects information (a selected date, a confirmation boolean, a form result) that the caller needs after the user navigates back. Navigator.pop() accepts an optional result value. On the calling side, Navigator.push() returns a Future that resolves with that result when the route is popped.

Example: Returning a Selected Color from a Picker Screen

// Picker screen: pops with the chosen color string
class ColorPickerScreen extends StatelessWidget {
  const ColorPickerScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Pick a Color')),
      body: Column(
        children: [
          ListTile(
            title: const Text('Red'),
            onTap: () => Navigator.pop(context, 'red'),
          ),
          ListTile(
            title: const Text('Green'),
            onTap: () => Navigator.pop(context, 'green'),
          ),
          ListTile(
            title: const Text('Blue'),
            onTap: () => Navigator.pop(context, 'blue'),
          ),
        ],
      ),
    );
  }
}

// Caller screen: awaits the Future returned by Navigator.push()
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  String _selectedColor = 'none';

  Future<void> _openColorPicker() async {
    final String? result = await Navigator.push<String>(
      context,
      MaterialPageRoute(builder: (context) => const ColorPickerScreen()),
    );

    // result is null if the user pressed the system Back button
    // without calling Navigator.pop(context, value)
    if (result != null) {
      setState(() {
        _selectedColor = result;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Selected color: $_selectedColor'),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _openColorPicker,
              child: const Text('Pick a Color'),
            ),
          ],
        ),
      ),
    );
  }
}
Warning: Always check if the result is null before using it. The user can dismiss a pushed route by pressing the device Back button, the AppBar back arrow, or swiping (on iOS). In all of these cases Navigator.pop() is called without a value, so the Future resolves to null. Ignoring this is a common source of Null check operator used on a null value crashes.

Passing Data with Named Routes

When you use named routes (defined in MaterialApp.routes), you cannot pass constructor arguments directly. Instead, use Navigator.pushNamed() with the arguments parameter and retrieve them inside the destination with ModalRoute.of(context)!.settings.arguments.

Named Route Data Passing

// Sending data with pushNamed
Navigator.pushNamed(
  context,
  '/product-detail',
  arguments: product,   // any Object
);

// Receiving data inside the destination widget
@override
Widget build(BuildContext context) {
  final product =
      ModalRoute.of(context)!.settings.arguments as Product;

  return Scaffold(
    appBar: AppBar(title: Text(product.name)),
    body: Text('Price: \$${product.price}'),
  );
}
Note: Named routes with arguments are not type-safe — you must cast manually and a mismatch causes a runtime error. For new projects, prefer the constructor-parameter approach or adopt GoRouter (which supports typed extra arguments).

Summary

Flutter gives you straightforward, idiomatic patterns for screen-to-screen data flow:

  • Forward: Add required constructor parameters to the destination widget and pass values when calling Navigator.push().
  • Backward: Call Navigator.pop(context, result) in the destination; await Navigator.push() in the caller to receive the result.
  • Named routes: Use the arguments parameter of pushNamed() and retrieve with ModalRoute.of(context)!.settings.arguments, casting to the expected type.
  • Always guard against a null result when awaiting a pushed route — the user may navigate back without supplying one.