← Back to posts Cover image for Mastering Ephemeral Flows with GoRouter: A Practical Guide for Checkout and Onboarding

Mastering Ephemeral Flows with GoRouter: A Practical Guide for Checkout and Onboarding

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Handling flows like checkout or onboarding in Flutter presents a unique challenge. These are ephemeral flows—self-contained sequences of screens where state is temporary and should be completely discarded when the user finishes or cancels. Think of filling a shopping cart, stepping through a tutorial, or submitting a multi-part form. The core problem is: how do you manage the state for these flows cleanly, ensuring it doesn’t leak into your app’s global state and is properly disposed of when the flow ends?

Many developers reach for their global state management solution (like Riverpod or Bloc) and create a long-lived provider or bloc for the flow. This often leads to headaches: you must remember to manually reset the state when the flow is done, and if the user backgrounds the app and returns later, you might be left with stale, invalid data. The route stack and your state become out of sync.

The key insight is that the navigation stack itself is a form of state. GoRouter, with its declarative routing, is excellent for modeling your app’s core navigation structure. For ephemeral flows, we can leverage GoRouter to scope our state to the lifetime of the flow’s route hierarchy. Here are two practical strategies.

Strategy 1: The Stateful Shell Route

A ShellRoute in GoRouter can provide a stateful wrapper for a group of sub-routes. This is perfect for an ephemeral flow. The shell creates and holds the state object (like a ChangeNotifier or a Cubit), provides it to all screens within the flow, and ensures it is disposed when the user navigates out of the shell.

Let’s model a checkout flow with a cart that persists through multiple screens but should vanish when done.

// The ephemeral state for our checkout flow
class CheckoutFlowState extends ChangeNotifier {
  List<CartItem> items = [];
  String? shippingAddress;
  PaymentMethod? paymentMethod;

  void addItem(Product product) {
    items.add(CartItem(product: product, quantity: 1));
    notifyListeners();
  }

  void clear() {
    items.clear();
    shippingAddress = null;
    paymentMethod = null;
    notifyListeners();
  }
}

// A ShellRoute that provides this state
final goRouter = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    ShellRoute(
      builder: (context, state, child) {
        // This provider is scoped ONLY to routes inside this shell.
        return ChangeNotifierProvider(
          create: (context) => CheckoutFlowState(),
          child: CheckoutFlowScaffold(child: child),
        );
      },
      routes: [
        GoRoute(
          path: 'checkout/cart',
          builder: (context, state) => const CartScreen(),
        ),
        GoRoute(
          path: 'checkout/shipping',
          builder: (context, state) => const ShippingScreen(),
        ),
        GoRoute(
          path: 'checkout/payment',
          builder: (context, state) => const PaymentScreen(),
        ),
        GoRoute(
          path: 'checkout/confirmation',
          builder: (context, state) => const ConfirmationScreen(),
        ),
      ],
    ),
  ],
);

// Inside any screen in the flow, e.g., ShippingScreen
class ShippingScreen extends StatelessWidget {
  const ShippingScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // We can access the scoped state
    final flowState = context.watch<CheckoutFlowState>();
    return Column(
      children: [
        Text('You have ${flowState.items.length} items in your cart'),
        TextField(
          onChanged: (value) => flowState.shippingAddress = value,
          decoration: const InputDecoration(labelText: 'Address'),
        ),
        ElevatedButton(
          onPressed: () => context.go('/checkout/payment'),
          child: const Text('Continue to Payment'),
        ),
      ],
    );
  }
}

When the user navigates outside the /checkout/* shell—by completing the order or cancelling—the ShellRoute’s builder is unmounted, and the ChangeNotifierProvider disposes the CheckoutFlowState, cleaning up everything automatically.

Strategy 2: Initializing State via Route Parameters

For simpler flows, or when you need to launch a flow with specific initial data, you can pass essential parameters via the route path or extra state. The first screen in the flow is responsible for creating the local state (e.g., using a StatefulWidget or a Provider created with Provider.value).

// Route definition
GoRoute(
  path: 'onboarding/:userId',
  builder: (context, state) {
    final userId = state.pathParameters['userId']!;
    // Pass the parameter to the screen which will manage local state.
    return OnboardingFlowScreen(userId: userId);
  },
),

// The flow's root screen manages its own state.
class OnboardingFlowScreen extends StatefulWidget {
  final String userId;
  const OnboardingFlowScreen({super.key, required this.userId});

  @override
  State<OnboardingFlowScreen> createState() => _OnboardingFlowScreenState();
}

class _OnboardingFlowScreenState extends State<OnboardingFlowScreen> {
  // Local state for the ephemeral flow
  final PageController _pageController = PageController();
  final Map<String, dynamic> _collectedData = {};

  void _completeFlow() {
    // Process _collectedData...
    // Navigate out, which discards this widget and all its state.
    context.go('/home');
  }

  @override
  void dispose() {
    _pageController.dispose(); // Cleanup happens here.
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: PageView(
        controller: _pageController,
        children: [
          OnboardingStep1(
            onData: (data) => _collectedData['step1'] = data,
            onNext: () => _pageController.nextPage(duration: const Duration(milliseconds: 300), curve: Curves.easeIn),
          ),
          OnboardingStep2(
            onData: (data) => _collectedData['step2'] = data,
            onComplete: _completeFlow,
          ),
        ],
      ),
    );
  }
}

Common Mistakes to Avoid

  1. Using Global State: Storing ephemeral flow data in a global app-wide provider forces you to add complex reset logic and risks state contamination.
  2. Ignoring Disposal: Forgetting to dispose controllers, listeners, or streams created for the flow can cause memory leaks. Using a scoped Provider or a StatefulWidget ensures disposal is tied to the widget lifecycle.
  3. Over-Engineering Paths: Don’t feel pressured to model every single piece of flow state in the URL. The URL should enable deep linking to key steps (like /checkout/payment), but the contents of the shopping cart can be held in memory-scoped state.

Conclusion

The goal is to align your state’s lifetime with your navigation stack’s lifetime. For ephemeral flows, leverage GoRouter’s ShellRoute to create a stateful boundary, or manage local state within a StatefulWidget at the root of the flow. This keeps your code clean, predictable, and free from the subtle bugs that come from leftover state. By letting navigation drive state lifetime, you create a more robust and maintainable user experience.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Flutter for High-Performance Desktop: Is it Ready for CAD, Image Processing, and Complex GUIs?

Developers are curious about Flutter's capabilities beyond typical business apps, especially for demanding desktop applications like CAD/CAM or image/video processing. This post will explore Flutter's suitability for high-performance, viewport-based desktop GUIs, discussing Dart's memory model, the 60fps update loop, and real-world examples to gauge its readiness for 'serious' complex software.

Cover image for Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Debugging Flutter Web Navigation: Solving the Deep Link Refresh Bug

Flutter web applications often suffer from a frustrating 'deep link refresh bug' where refreshing the browser on a nested route (e.g., /home/details) bounces the user back to the root or an incorrect path. This post will diagnose the common causes of this issue, explain how Flutter's router handles web URLs, and provide practical solutions and best practices for building robust, refresh-proof navigation in your Flutter web apps.

Cover image for Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

Mastering Internationalization in Flutter: Centralized Strings for Scalable Apps

As Flutter applications grow, managing strings for multiple languages or just keeping text consistent becomes a challenge. This post will guide developers through effective strategies for centralizing strings, implementing robust internationalization (i18n) and localization (l10n), and leveraging tools to streamline the process for small to large-scale projects.