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
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.
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.
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.