Building Dynamic Navigation Trees with Riverpod and GoRouter
Bridging the Gap: Navigation in Unstable Environments
When we deploy our community health reporting tools in remote areas of Sierra Leone, we are not just designing for a stable office environment. We are designing for the bumpy ride on the back of a motorbike, the humidity of the rainforest, and the persistent reality of zero-signal zones. In these conditions, a health worker needs to focus on the patient, not the application state. When they open our Flutter app, they expect it to remember exactly where they were, even if they have been offline for three days.
In our current architecture, navigation is not just a UI concern; it is a reflection of the workflow. If a health worker is halfway through an immunization survey and the signal drops, the navigation stack must remain intact. If the app restarts or the session expires, the dynamic navigation tree must reconstruct itself based on the last known state stored in our local 72-hour buffer. By pairing Riverpod for state management and GoRouter for declarative routing, we have built a system that feels fluid to the user, regardless of whether the Firebase cloud is currently reachable.
The Philosophy of State-Driven Navigation
Traditional imperative navigation—pushing and popping routes manually—often leads to "ghost states" where the app thinks it is on one screen while the local database suggests the user is somewhere else. In a humanitarian context, this is a dangerous discrepancy. We need our navigation to be a deterministic function of our application state.
Riverpod acts as our single source of truth. By defining our navigation state as a NotifierProvider, we ensure that the navigation tree is an observable entity. When the user transitions from a "Patient Intake" form to a "Vaccination Record," the state updates, and GoRouter responds instantly. Because we use Riverpod, we can easily inject our local sync engine status into the navigation guards, allowing us to prevent navigation to screens that require a server round-trip if the sync queue is currently offline.
Step-by-Step: Implementing the Dynamic Router
To build a navigation tree that is both dynamic and offline-robust, we need to separate the definition of our routes from the logic of our access. Here is how we bridge Riverpod and GoRouter.
1. Defining the Navigation State
First, we create a provider that tracks the user’s auth and sync state. This state determines whether the user is allowed to navigate past the initial login or the offline dashboard.
final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<AuthState> {
final Ref ref;
AuthNotifier(this.ref) : super(AuthState.initial());
void login(String userId) {
state = AuthState.authenticated(userId);
}
}
2. Configuring the GoRouter
We use a redirect logic within our GoRouter instance. This ensures that every route change is evaluated against the current state of the application. If a health worker tries to access a restricted data-sync module while offline, the router can proactively redirect them to the local 'Queue Status' screen.
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
initialLocation: '/dashboard',
refreshListenable: GoRouterRefreshStream(ref.watch(authStateProvider.notifier)),
redirect: (context, state) {
if (!authState.isAuthenticated && state.uri.path != '/login') {
return '/login';
}
return null;
},
routes: [
GoRoute(path: '/login', builder: (c, s) => LoginScreen()),
GoRoute(path: '/dashboard', builder: (c, s) => DashboardScreen()),
GoRoute(path: '/sync-status', builder: (c, s) => SyncQueueScreen()),
],
);
});
Managing the Sync Queue Integrity
The 72-hour buffer I mentioned previously is the backbone of our work. When a health worker adds a record to our local storage, the navigation tree needs to reflect that the data is 'pending'. We achieve this by using a StreamProvider that listens to our local SQLite/Hive database changes.
If the user is on the 'Records' screen, the navigation tree remains stable, but the UI components observe the local database. If the sync fails due to connectivity, the navigation controller doesn't kill the session; instead, it marks the route as 'locally cached'. We want the user to trust that their work is saved, even if the upload button is greyed out. By keeping the navigation separate from the sync logic, we avoid the jarring experience of the app hanging or crashing while waiting for a network handshake that isn't coming.
Pro-Tips for Humanitarian App Architecture
- Atomic Navigation Updates: Never update the navigation stack and the database in two separate steps if they depend on each other. Wrap them in a transaction where the route change is the final 'commit' only after the local store confirms the write.
- The 'Offline' Scaffold: Always include a visual indicator in your global scaffold that reflects the status of the sync queue. This is a navigation-agnostic way to keep the user informed without changing their current view.
- Preserving State in Transitions: Use
GoRouter'sextraparameter to pass temporary context (like a selected patient ID) during navigation, but always keep a backup of this reference in Riverpod. If the app is killed while in the background, a simple rebuild can re-fetch the patient ID from the local buffer and re-hydrate the route. - Handle the 'Empty' State: When navigating to a list view of pending sync items, always handle the case where the user has no connectivity. Provide a clear, empathetic explanation of why the screen is empty, rather than a loading spinner that spins indefinitely.
Ensuring Data Integrity Amidst Navigation
The most critical aspect of our navigation logic is the 'Conflict Resolution' check. When a user navigates from the local entry form back to the main dashboard, the app triggers a silent re-validation of the local record against the Firebase docId. If the local data has drifted from what we believe is the server state, we don't force an immediate override. We navigate the user to a 'Reconciliation View'.
This is where Riverpod shines. Because our data service is a provider, we can switch the 'sync engine' into a manual resolution mode. The user sees their locally saved data on the left and the server data on the right. This keeps the health worker in control. We do not treat our data as purely transactional; we treat it as clinical information that requires human judgment. The navigation tree simply facilitates this judgment by making the 'Reconciliation' screen just another branch in the user's flow.
Scalability and Future-Proofing
As our health projects grow, the complexity of our navigation trees increases. We are currently moving toward nested navigators to support multi-step clinical assessments. Each step (e.g., Vitals, Diagnosis, Treatment) is a route, and each route needs to be saved to our local buffer. Using GoRouter, we can nest these flows while maintaining the same Riverpod-driven authentication and sync guards.
When I see a health worker in a remote village successfully logging a child’s vaccination data without a single bar of signal, I don't see the complexity of the code I've written. I see the reliability of the tools. The code is merely the invisible scaffold. By ensuring that our navigation is dynamic, reactive, and always aware of the offline context, we allow the health worker to stay focused on the patient. We aren't just building apps; we are building trust. And in the field, trust is the most important bandwidth of all.
By keeping our routing logic strictly coupled to the application state in Riverpod and loosely coupled to the UI via GoRouter, we have created a system that can be tested, mocked, and deployed with confidence. We have tested our navigation flow with thousands of offline interactions, and the beauty of this architecture is that the user never needs to know the engineering weight behind their simple screen tap. To them, the app just works. That is the goal of every line of code we write in Freetown.