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.

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

Example · tsx
// 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.

Example · tsx
// 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.

Example · tsx
// 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)/feed

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

Example · tsx
// 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

  • Be the first to comment on this lesson.