← 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's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.