Published on

The Single-Driver Principle for Declarative Routing: Why There Can Be Only One Source of Navigation Truth

Authors
  • avatar
    Name
    Jack Qin
    Twitter

The core promise of declarative routing is this: you no longer command the app to go somewhere, you declare which screen should be shown for a given state, and the framework converges the current state onto the matching view. Expo Router's filesystem groups ((auth) / (tabs)) plus <Redirect> are the concrete form of that promise — (auth)/_layout declares "if logged in, go to tabs," and (tabs)/_layout declares "if logged out, go back to login." Get the state right and navigation happens on its own.

This model rests on a premise that's easy to overlook but underpins its entire correctness: there must be exactly one source of navigation truth. Once a declarative gate is in place, any other imperative navigation aimed at the same destination is not a "belt and suspenders" safeguard — it's a second driver. This post isn't about one login-flicker bug; it's about the mechanism behind the principle: why a declarative gate is hostile to imperative navigation, and how a subtler timing premise — the derived auth state flips before await even returns — turns a "harmless-looking line of router.replace" into a structural race.


Mechanism 1: A declarative gate is a converger, not a one-shot jump

The mental model for imperative navigation (router.replace("/(tabs)")) is "perform one jump" — it's an event. The mental model for a declarative gate is completely different: it's a continuously running convergence function. Every time isAuthenticated changes, the mounted _layout re-renders, re-evaluates "should I redirect right now," and emits or withdraws a <Redirect> accordingly.

// app/(auth)/_layout.tsx —— leave the auth area once logged in
if (isAuthenticated) return <Redirect href="/(tabs)" />

// app/(tabs)/_layout.tsx —— go back to the auth area when logged out
if (!isAuthenticated) return <Redirect href="/(auth)/login" />

The key difference: the converger automatically sends you to the target as soon as the state is right — nobody has to trigger it explicitly. This means that, given a gate that has already taken over, writing one more router.replace("/(tabs)") is not "lending a hand." It introduces a second driver aimed at the same target as the converger, but fired on a schedule the converger doesn't control. Two drivers pointed at one destination is itself a breeding ground for races — who arrives first, and how the framework handles "already en route to X, now commanded to X again," are both undefined behavior.


Mechanism 2: The derived state flips before the promise resolves

If the two drivers were always strictly sequential and never overlapped, the problem might stay luckily hidden. What actually nails it down to an inevitability is a second timing premise — and it's buried inside the implementation of AuthContext.login().

login() / loginWithMicrosoft() internally calls queryClient.fetchQuery(currentUserQueryOptions). The side effect of that call is crucial: it writes the ["auth","me"] cache before the promise returned by login() resolves. And isAuthenticated is reactively derived from that cache — currentUser comes from useCurrentUserQuery, and isAuthenticated is !!currentUser?.isActive.

Wire those two facts together and the conclusion is counterintuitive: isAuthenticated flips to true while login() is still mid-flight, while the caller is still stuck on await. In other words, for the caller, the line await login() doesn't mean "login finished" — it means "login finished, and the gate started navigating a while ago."

So the race takes shape:

  1. Cache written → isAuthenticated flips true → the currently mounted (auth)/_layout re-renders → emits <Redirect href="/(tabs)"> (driver #1, fired automatically by the converger);
  2. All of this happens before that still-awaiting caller reaches its own line router.replace("/(tabs)") (driver #2).

Both replaces hit /(tabs) in the same JS tick, and Expo Router replays/interrupts the transition — what your eyes see is the screen flickering and bouncing several times before settling. Note there's no splash to cover it: the user is already on the login form by now, isLoading was false long ago, and there's no loading state to borrow as a fig leaf.


Where the two premises meet

Stack the two mechanisms and the flicker goes from "occasional bug" to "structural inevitability":

The declarative gate is the only navigation driver there should be (Mechanism 1) → but login() writes the cache and flips the derived isAuthenticated before resolving (Mechanism 2) → the gate emits its <Redirect> first → the caller's subsequent redundant router.replace becomes a second driver aimed at the same target → two navigations in one tick → Expo Router replays the transition → visible flicker.

What's worth savoring is that each driver, viewed in isolation, is "correct": the gate is the standard idiom of declarative routing; router.replace("/(tabs)") after login is perfectly correct in an imperative app that has no gate. What's wrong is the unexamined implicit premise at their intersection — the router.replace line was written under a worldview where "I drive navigation by hand," and once the gate takes over navigation truth, that premise lapses, and the redundant line flips from "harmless redundancy" to "second driver of a race."

This also explains why these problems are so disorienting to debug: the symptom (screen flickers several times) looks like a render-performance or animation issue, which lures you toward orthogonal axes like "add a splash to cover it" or "throttle the re-renders." But the real fault axis is the number of navigation drivers — as long as there are two, every patch that hides the symptom leaves the cause untouched.


The fix, and the contract it forces out

The direct fix is subtraction — delete the caller's imperative navigation and let the gate be the sole driver:

// LoginFormCard.tsx —— no longer needs the expo-router import
await loginWithMicrosoft()
// The gate in app/(auth)/_layout.tsx is the only navigation driver.

If deleting the last router.* call leaves import { router } from "expo-router" unused, delete it too — don't leave orphans.

But more important than those few deletions are the judgments it forces out:

The semantics of await login() must be written as an executable contract. The implicit agreement of this call is "on resolve, isAuthenticated is already true and the gate has started navigating." A verbal agreement won't hold — the next person could easily "play it safe" and add another router.replace.

State after await login()Gate behaviorWhat the caller should do
Success (cache written, isAuthenticated → true)(auth)/_layout redirects to /(tabs)Do nothing — call no router.*
Failure (login() throws, no cache written)Gate unchanged, stays in (auth)Render the error; don't navigate

Any authenticated user landing on any (auth) route is handled by the <Redirect> in (auth)/_layout, not by a redirect patched in at the screen layer. Cold-start restoration is the same — it's handled by the bootstrap effect plus the gate, and was never within range of this rule.

The "no duplicate navigation" constraint must become a regression guard. In the login screen's unit test (Vitest, with vi.mock("expo-router") mocking out router.replace; this project's renderHook is unavailable, so we test the component-level contract instead), the core assertion on the success path is not "navigation succeeded" but expect(mockedRouterReplace).not.toHaveBeenCalled() — that "no imperative navigation was called" assertion is the gatekeeper of the single-driver principle. Whoever resurrects the second driver turns it red on the spot.


The transferable layer

Set aside Expo Router's specific API, and there are two transferable lessons in this case.

In a declarative system, the imperative escape hatch is the default anti-pattern. Once you've used state to declare "what should be shown," reaching for an imperative push toward the same result almost always collides with the declarative converger at some timing boundary. Declarative UI, declarative routing, declarative data sync — their correctness all rests on "one source of truth," and adding an imperative driver is breaking that premise.

The "done" boundary of an async function may not be the boundary you think it is. await login() looks like "the login thing is over," but the cache write inside it has already flipped the derived state and kicked off the downstream convergers. When an async operation carries a side effect that something elsewhere reactively subscribes to, it becomes "externally visible" before its promise resolves. To debug this kind of race, instead of hunting one by one for who triggered the navigation, first ask: how many things on this path are driving the same result? Have I converged it down to one?