Lesson 31 of 51 intermediate

Passing Data Between Screens

Sending luggage on your route trips

Open interactive version (quiz + challenge)

Real-world analogy

Imagine you are sending a friend on a road trip. You pack a suitcase (route arguments) with everything they need at the destination. Sometimes you write a note on the suitcase tag (query params), and sometimes your friend brings back a souvenir when they return (returning data). In Flutter, screens pass data the same way -- you send arguments forward, read parameters from the URL, and pop results back.

What is it?

Passing data between screens is the process of sending information from one screen to another during navigation. Flutter with GoRouter supports three main approaches: path parameters embedded in the URL, query parameters appended to the URL, and in-memory extras for complex objects. Screens can also return data when they pop, enabling two-way communication between screens.

Real-world relevance

Every real app needs to pass data between screens. When a user taps a product in a list, the detail screen needs the product ID. When a user fills out a multi-step form, each step passes accumulated data forward. When a user picks a photo from a gallery, the selected image is returned to the calling screen. Mastering data passing makes your navigation feel seamless and your code maintainable.

Key points

Code example

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

// Define a typed data class for route arguments
class ProductArgs {
  final String id;
  final String name;
  const ProductArgs({required this.id, required this.name});
}

// Route configuration
final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/product/:id',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        final args = state.extra as ProductArgs?;
        return ProductScreen(id: id, name: args?.name);
      },
    ),
    GoRoute(
      path: '/color-picker',
      builder: (context, state) => const ColorPickerScreen(),
    ),
  ],
);

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Shop')),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Flutter Widget Book'),
            onTap: () {
              context.push(
                '/product/101',
                extra: const ProductArgs(
                  id: '101',
                  name: 'Flutter Widget Book',
                ),
              );
            },
          ),
          ListTile(
            title: const Text('Pick a theme color'),
            onTap: () async {
              final color = await context
                  .push<Color>('/color-picker');
              if (color != null && context.mounted) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('Picked: $color')),
                );
              }
            },
          ),
        ],
      ),
    );
  }
}

class ProductScreen extends StatelessWidget {
  final String id;
  final String? name;
  const ProductScreen({super.key, required this.id, this.name});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(name ?? 'Product $id')),
      body: Center(child: Text('Product ID: $id')),
    );
  }
}

class ColorPickerScreen extends StatelessWidget {
  const ColorPickerScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final colors = [Colors.red, Colors.teal, Colors.purple];
    return Scaffold(
      appBar: AppBar(title: const Text('Pick a Color')),
      body: ListView(
        children: colors.map((c) => ListTile(
          tileColor: c,
          title: Text(c.toString()),
          onTap: () => context.pop(c),
        )).toList(),
      ),
    );
  }
}

Line-by-line walkthrough

  1. 1. Import Flutter material library and GoRouter package for navigation.
  2. 2. Define a ProductArgs class with id and name fields to carry typed data between screens.
  3. 3. Create the GoRouter with three routes: home at root, product with a path parameter, and color-picker.
  4. 4. The product route reads the id from pathParameters and optionally reads ProductArgs from extra.
  5. 5. HomeScreen is a StatelessWidget with a ListView of navigation options.
  6. 6. The first ListTile navigates to product screen using context.push with both a path param and extra args.
  7. 7. The second ListTile navigates to color-picker and awaits the returned Color value.
  8. 8. After the push completes, check if color is not null and context is still mounted before showing a SnackBar.
  9. 9. ProductScreen receives id and optional name through its constructor, displaying them in the AppBar and body.
  10. 10. ColorPickerScreen shows a list of colors. Tapping one calls context.pop(c) to return the selected color.
  11. 11. The map function creates a ListTile for each color, with tileColor set to visually preview each option.

Spot the bug

class DetailScreen extends StatelessWidget {
  const DetailScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final args = GoRouterState.of(context).extra as UserData;
    return Scaffold(
      appBar: AppBar(title: Text(args.name)),
      body: ElevatedButton(
        onPressed: () async {
          final result = context.push<bool>('/confirm');
          if (result) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Confirmed!')),
            );
          }
        },
        child: const Text('Confirm'),
      ),
    );
  }
}
Need a hint?
Two issues: one about awaiting a Future, and one about null safety on the returned value.
Show answer
First, context.push<bool>('/confirm') returns a Future<bool?> but is missing the await keyword. Second, the result can be null if the user presses back, so the null check should be 'if (result == true)' instead of 'if (result)'. The corrected code: 'final result = await context.push<bool>("/confirm"); if (result == true) { ... }'

Explain like I'm 5

Imagine you are playing a treasure hunt game. At each station, the game master gives you a card with clues for the next station. Sometimes the clue is written right on the map (that is like a path parameter in the URL). Sometimes there is a secret note taped under the card that only you can see (that is like extras). And sometimes, when you finish a station, you bring back a prize to show the game master (that is like returning data with pop). Each way of passing information has its own purpose!

Fun fact

In the early days of Flutter, passing data between screens meant manually constructing route objects and casting arguments from generic Object types. GoRouter's typed extras and path parameters were a huge quality-of-life improvement that the community had been requesting since 2018.

Hands-on challenge

Create a two-screen app: a ListScreen showing 5 fictional books with titles and authors, and a DetailScreen that receives the book data via GoRouter extras. Add a 'Rate this book' button on the DetailScreen that navigates to a RatingScreen. The RatingScreen should pop back a rating (1-5), and the DetailScreen should display the returned rating. Use typed data classes for all route arguments.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart