In an Expo Router app, how do you restore a saved session at launch so the sign-in screen never flashes before the account area appears?
answer
- unknown is not signed out
- provider in the root layout
- guards read context in a child
- SplashScreen.preventAutoHideAsync, then hide
- sign-out needs no router call
basics
~20 sKeep the native splash screen up with SplashScreen.preventAutoHideAsync() until an auth provider in the root layout has read the stored session, then hide it. Stack.Protected guards read that provider, so the first visible frame already has the right screens.
solid answer
~40 sThe flash happens because the stored session is read asynchronously, and treating "not loaded yet" as signed out makes the first render show `sign-in`. I put an **auth provider in the root layout** exposing `session` and `isLoading`, and a child `RootNavigator` inside it whose `Stack.Protected` guards read `!!session` and `!session`. At module scope I call `SplashScreen.preventAutoHideAsync()` from `expo-router`, and a small controller calls `SplashScreen.hide()` once `isLoading` is false, so nothing is visible until the guards are right. Because a guard removes routes instead of redirecting after mount, there is no wrong screen to bounce away from. Sign-out just clears the session: the guard flips, the account history is dropped, and no manual navigation is needed.
code
tsx · 39 lines// src/app/_layout.tsx
import { useEffect } from 'react';
import { SplashScreen, Stack } from 'expo-router';
import { SessionProvider, useSession } from '../session';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
return (
<SessionProvider>
<SplashController />
<RootNavigator />
</SessionProvider>
);
}
function SplashController() {
const { isLoading } = useSession();
useEffect(() => {
if (!isLoading) {
SplashScreen.hide();
}
}, [isLoading]);
return null;
}
function RootNavigator() {
const { session } = useSession();
return (
<Stack>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
</Stack.Protected>
</Stack>
);
}go deeper
Recall that reading a saved session is asynchronous and that the app must not show sign-in until that read has finished.
Explain the three pieces: a provider in the root layout, guards in a child that reads it, and the splash held with preventAutoHideAsync until loading ends.
Handle the edges: a tri-state auth value, hiding the splash when storage fails, expired tokens, and why guards avoid the mount-then-redirect frame and stale history.
Weigh launch time against correctness: validating the session before hiding the splash delays the first frame, while validating after it trades a flicker for a deliberate later sign-out.
## Where the flicker comes from A **navigation flicker** at launch is the sign-in screen appearing for a few frames and then being replaced by the signed-in home. In an Expo Router app it has one root cause: **the root layout renders the navigator before the saved session has been read**. Reading a stored session is asynchronous on a device. Until the promise resolves, the auth state is "unknown", and if the app encodes "unknown" as "signed out", then: 1. The first render computes `guard={!!session}` as `false` for the account area and `true` for `sign-in`. 2. The stack shows `sign-in`, and the native splash screen hides as the first frame appears. 3. The stored session arrives, the guards flip, and the router moves the user to the signed-in screens. The user sees step 2. The fix is to make sure no screen is visible until step 3 has happened. ## The three pieces **1. An auth provider in the root layout.** A React context provider rendered by `src/app/_layout.tsx` owns the session and exposes `session`, `isLoading`, `signIn` and `signOut`. Because it wraps the whole navigator, every screen and every guard reads the same value. **2. A child component that reads the context.** The component that renders `<SessionProvider>` cannot read that provider's context itself, so the guards live in a child, conventionally `RootNavigator`, rendered inside the provider: ```tsx function RootNavigator() { const { session } = useSession(); return ( <Stack> <Stack.Protected guard={!!session}> <Stack.Screen name="(app)" /> </Stack.Protected> <Stack.Protected guard={!session}> <Stack.Screen name="sign-in" /> </Stack.Protected> </Stack> ); } ``` **3. The splash screen held until loading ends.** Expo Router exports a `SplashScreen` object. Calling `SplashScreen.preventAutoHideAsync()` at module scope keeps the native splash visible after the first render; a small controller calls `SplashScreen.hide()` once `isLoading` is `false`. Whatever the navigator renders during the unknown period sits behind the splash, and the first frame the user actually sees already has the right guards. ## Why guards remove the flicker a redirect had Before protected routes, the documented pattern put a redirect inside a group layout: the layout rendered, noticed there was no session, and navigated away. That approach **mounts the wrong screen first** and then moves, so a frame of it can show, and its history may keep entries the user should not return to. A `Stack.Protected` guard changes the navigator's route list instead. With the guard false, the account screens are not in the list at all, so there is nothing to mount and nothing to bounce away from. Combined with the held splash, the first visible frame is correct. ## Sign-in and sign-out without manual navigation | Event | Guard change | What the router does | |---|---|---| | Launch, session found | `(app)` true, `sign-in` false | Starts on the app group behind the splash | | Launch, no session | `(app)` false, `sign-in` true | Starts on sign-in behind the splash | | Sign-out | `(app)` false | Drops every `(app)` history entry; shows sign-in | | Sign-in | `sign-in` false | Drops the sign-in entry; shows the app group | - **Sign-out needs no `router` call.** Clearing the session flips the guard; the router removes the account screens' history entries, so a back gesture cannot return to the billing screen of a streaming app. - **Sign-in** likewise removes the `sign-in` entry once its guard turns false. The Expo guide still calls `router.replace('/')` after `signIn()` to choose the destination explicitly; either way, set the session **before** navigating, because a push to a screen whose guard is still false is not shown. - **Keep one source of truth.** A guard computed from a second, independently cached flag can disagree with the provider and bounce the user between groups. ## Production details worth knowing - **Treat "loading" as its own state.** A tri-state (`isLoading`, then `session` or `null`) keeps the controller honest; conflating loading with signed out is the bug itself. - **Hide the splash on failure too.** If reading storage throws, set `isLoading` to `false` with no session, or the app sits on the splash forever. - **Expired sessions.** A stored token may be present but rejected by the server. Decide whether the guard means "has a stored session" or "has a validated session"; validating before hiding the splash costs launch time, validating after means a later, deliberate sign-out rather than a flicker. - **Guards are client-side.** They decide which screens render; the streaming service's API must still reject an unauthenticated request for account data. This is the pattern the Expo documentation describes for Expo SDK 53 and later, including the Expo SDK 57 target with Expo Router 57.x.
- Why can't the root layout component call useSession() itself if it renders the SessionProvider?A component reads a context from providers above it in the tree. The root layout renders `SessionProvider`, so the provider is its child, not its ancestor, and a `useSession()` call there sees no provider. Moving the navigator and its guards into a child component such as `RootNavigator`, rendered inside the provider, gives them access to the session.
- What should the provider do if reading the stored session throws?Finish loading anyway: set `isLoading` to false with no session and report the error. The splash controller hides the splash only when loading ends, so a rejected promise that never clears `isLoading` leaves the app stuck on the splash screen. Falling back to signed out shows the sign-in screen, which is a recoverable state.
- After signIn(), the app pushes to an account screen and nothing happens. What is the likely cause?The navigation ran against a guard that was still false, for example because an async `signIn()` was not awaited or the guard reads a different store, so the target was not in the route list. Update the session the guards read first, then navigate; or rely on the guards, since the sign-in screen's own guard turning false drops it from the stack and reveals the signed-in group.
saying these in an interview costs you the question
- Initialise session to null so the app starts signed out
- Hide the splash on first render, then redirect once storage loads
- Call useSession() in the component that renders the provider
- After signOut() you must call router.replace('/sign-in') yourself
- Stack.Protected guards also protect the account API
- A redirect in the account layout avoids any wrong frame