← 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 Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Optimizing Flutter UI Performance: Best Practices for Date Formatting and Expensive Operations

Developers often face performance bottlenecks when performing expensive operations like date formatting directly within Flutter's `build` method, especially in fast-scrolling lists. This post will delve into common pitfalls, explain why these operations are costly, and provide practical strategies for optimizing UI performance by caching formatters, using `initState`, and leveraging `compute` for background processing without blocking the UI.

Cover image for Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Optimizing Your Flutter Dev Setup: IDEs, Simulators, and AI Tools for Peak Productivity

Flutter developers frequently seek to refine their development environments. This post will dive into popular IDE choices like VS Code and Android Studio, discuss best practices for managing iOS and Android simulators (including in-IDE options), and explore the practical integration of AI tools for code generation and problem-solving to boost overall efficiency.

Cover image for Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Demystifying Flutter Performance: Practical Strategies for Large-Scale Apps

Flutter's performance is often blamed for issues in complex applications, but the real culprits are usually architectural decisions, inefficient widget rebuilds, and unoptimized resource handling. This post will dive into common performance bottlenecks in large Flutter apps, providing actionable strategies for profiling, optimizing state management, handling images and network requests efficiently, and leveraging CI/CD for continuous performance monitoring.