Mastering Ephemeral Flows with GoRouter: A Practical Guide for Checkout and Onboarding
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
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
- Using Global State: Storing ephemeral flow data in a global app-wide provider forces you to add complex reset logic and risks state contamination.
- Ignoring Disposal: Forgetting to dispose controllers, listeners, or streams created for the flow can cause memory leaks. Using a scoped
Provideror aStatefulWidgetensures disposal is tied to the widget lifecycle. - 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
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.
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.
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.