Navigation State and Restoring It
What the navigation state object contains, how to save and restore it across a restart, and when a cold launch should start fresh instead.
<NavigationContainer initialState={savedState} onStateChange={(state) => persist(state)}>Every navigator keeps a plain JavaScript object describing what is on screen. It is a tree: each state has a type (stack, tab, drawer), a key, a routeNames array of the screens it knows, a routes array acting as the history, and index pointing at the focused route. A route has key, name, optional params, and optional state for a nested navigator. A state is treated as stale unless stale: false is set explicitly.
Persisting it
In plain React Navigation, NavigationContainer takes initialState to start from a saved state and onStateChange to be told when it changes. Save the state as JSON on change, read it back before the first render. Everything in it must be JSON-serializable, which is the real reason params should never hold a function, a class instance or a Date.
Expo Router owns the container, so you do not pass initialState. There you have something simpler: the state corresponds to a URL. Remember the last pathname and navigate to it on launch.
Should it resume at all
Three rules. A deep link always wins, so skip restoration when the app was opened by a URL. Never restore onto a screen that crashed, or the app becomes unopenable without a reinstall. Version the storage key, because a saved state referring to screens you have since renamed restores into nothing.
Note saved?.length is shorthand for saved == null ? undefined : saved.length.
Example
// app/_layout.tsx — remembering the last route with Expo Router
import { useEffect, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Slot, usePathname, useRouter, type Href } from 'expo-router';
// Version the key: a path saved by an older build may no longer exist.
const LAST_PATH = 'last-path-v1';
export default function RootLayout() {
const pathname = usePathname();
const router = useRouter();
const [restored, setRestored] = useState(false);
const didRun = useRef(false);
useEffect(() => {
if (didRun.current) {
return;
}
didRun.current = true; // run the restore exactly once per launch
AsyncStorage.getItem(LAST_PATH)
.then((saved) => {
if (saved !== null && saved !== '/') {
// replace, not push: the user should not be able to go "back"
// to a screen they never visited this session.
// The cast is needed because a stored string is opaque to
// the typed-routes checker.
router.replace(saved as Href);
}
})
.finally(() => setRestored(true));
}, [router]);
useEffect(() => {
// Only start recording after the restore, or the first write
// overwrites the saved path with "/".
if (restored) {
AsyncStorage.setItem(LAST_PATH, pathname);
}
}, [restored, pathname]);
return <Slot />;
}When to use it
- Put a user back on the article they were reading after the OS kills the app in the background.
- Resume a multi-step onboarding flow at the step the user reached, instead of the first one.
- Keep the state restore out of the way when a push notification or deep link opens a specific screen.
- Log the focused route name from the navigation tree for a crash report.
More examples
Full state persistence with NavigationContainer
The complete version: the whole tree comes back, including nested tabs and the back history, not just the focused route. Use it when the user can be several screens deep inside a flow and losing that position is costly. It is also the version most likely to misbehave, because a saved state from an older build can reference screens that no longer exist.
// App.tsx — React Navigation without Expo Router
import { useEffect, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Linking from 'expo-linking';
import {
NavigationContainer,
type NavigationState,
} from '@react-navigation/native';
import { RootStack } from './RootStack';
const PERSISTENCE_KEY = 'NAVIGATION_STATE_V1';
export default function App() {
const [isReady, setIsReady] = useState(false);
const [initialState, setInitialState] = useState<NavigationState>();
useEffect(() => {
const restoreState = async () => {
try {
const initialUrl = await Linking.getInitialURL();
// A deep link must win over the saved state.
if (initialUrl === null) {
const saved = await AsyncStorage.getItem(PERSISTENCE_KEY);
if (saved !== null) {
setInitialState(JSON.parse(saved) as NavigationState);
}
}
} finally {
setIsReady(true);
}
};
restoreState();
}, []);
// Rendering the container before the read finishes would show
// the default screen and then jump.
if (!isReady) {
return null;
}
return (
<NavigationContainer
initialState={initialState}
onStateChange={(state) => {
// state is undefined while the container tears down.
if (state !== undefined) {
AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state));
}
}}
>
<RootStack />
</NavigationContainer>
);
}Reading the focused route out of a state tree
A pure function, so it is the one thing in this lesson you can reason about without a device. It shows why index and routes matter: the focused screen is routes[index], and a nested navigator hangs off that route's own state. The same walk is what a tracking helper does to turn a state tree into a single screen name.
// lib/focusedPath.ts
// A minimal shape of the parts of a navigation state used here.
type Route = { name: string; state?: State };
type State = { index: number; routes: Route[] };
const state: State = {
index: 1,
routes: [
{ name: 'index' },
{
name: '(tabs)',
state: { index: 0, routes: [{ name: 'feed' }] },
},
],
};
export function focusedPath(current: State): string {
const route = current.routes[current.index];
// Descend into the nested child state until a route has none.
return route.state
? `${route.name}/${focusedPath(route.state)}`
: route.name;
}
console.log(focusedPath(state));
// Output: (tabs)/feedClearing the saved state when the app crashes
The safety valve. Without something like it, a screen that throws on mount gets restored on every launch and the only fix a user has is deleting the app. The flag is deliberately crude: it only asks whether the last launch stayed up long enough to be plausible.
// app/_layout.tsx
import { useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Slot } from 'expo-router';
const LAST_PATH = 'last-path-v1';
const CLEAN_EXIT = 'clean-exit-v1';
export default function RootLayout() {
useEffect(() => {
const check = async () => {
const clean = await AsyncStorage.getItem(CLEAN_EXIT);
if (clean !== 'true') {
// The previous launch never reached a settled state.
// Forget where it was, rather than reopening the same screen.
await AsyncStorage.removeItem(LAST_PATH);
}
await AsyncStorage.setItem(CLEAN_EXIT, 'false');
// Mark the launch good once the UI has survived a moment.
setTimeout(() => AsyncStorage.setItem(CLEAN_EXIT, 'true'), 3000);
};
check();
}, []);
return <Slot />;
}
Discussion