Navigation State Persistence: Surviving an App Restart
AI generated
RN
native
React Native / Navigation
Navigation State Persistence: Surviving an App Restart
How navigation state gets restored and where deliberate limits are needed

When a React Native app gets force quit and reopened, the entire navigation state is lost by default, because both React Navigation and Expo Router keep the current screen and stack history exclusively in the running app instance's memory. For many apps that is a noticeable break in the experience, when a deeply nested view resets to the start screen after every restart. This article covers how navigation state can be persisted deliberately, what configuration that takes in React Navigation and Expo Router, and where that restoration should deliberately not happen, such as after an expired login.

10 min read Navigation State Persistence AsyncStorage

1. Why navigation state gets lost by default

Both React Navigation and Expo Router manage the current navigation state, meaning which screen is active and what stack history sits behind it, as a plain JavaScript object living in the running app instance's memory. Once the native process gets terminated, whether through a manual force quit, the operating system reclaiming memory, or a crash, that object disappears completely along with the rest of the app's runtime state. On the next launch, navigation gets fully reinitialized and starts at the configured start screen, regardless of where the user previously was.

This behavior differs fundamentally from native iOS and Android apps, where the operating system can in certain cases restore individual views' UI state across a process restart. React Native does not offer this restoration automatically, because the entire navigation tree exists as a flexible, dynamically generated data structure in the JavaScript layer with no direct equivalent in native view controller or activity state that the operating system could preserve on its own.

2. React Navigation's state persistence API

React Navigation ships a built in persistence API for exactly this problem, wired up through the onStateChange and initialState props on NavigationContainer. On every state change, meaning on every navigation, onStateChange reports the full new state as a serializable object, which can then be written to persistent storage such as AsyncStorage. On the next app launch, the app reads that saved state before the NavigationContainer's first render and passes it in through initialState, making React Navigation pick up exactly where it left off instead of starting at the configured start route.

Because reading from AsyncStorage happens asynchronously, the actual NavigationContainer needs to be held back until the saved state has actually loaded, typically through a simple isReady flag in component state. Without that delay, the container would already initialize with the default state, and the saved state arriving later would have no effect, since initialState is only ever evaluated on the very first render.


import { useState, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer, InitialState } from '@react-navigation/native';

const PERSISTENCE_KEY = 'NAVIGATION_STATE_V3';

export default function AppNavigation() {
  const [isReady, setIsReady] = useState(false);
  const [initialState, setInitialState] = useState<InitialState>();

  const restoreState = useCallback(async () => {
    const savedStateString = await AsyncStorage.getItem(PERSISTENCE_KEY);
    if (savedStateString) {
      setInitialState(JSON.parse(savedStateString));
    }
    setIsReady(true);
  }, []);

  if (!isReady) {
    return null;
  }

  return (
    <NavigationContainer
      initialState={initialState}
      onStateChange={(state) =>
        AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state))
      }
    >
      {/* navigator goes here */}
    </NavigationContainer>
  );
}

3. Concrete implementation step by step

In practice it pays off to tie loading the saved state to the app's general startup sequence, for example hiding a splash screen through expo-splash-screen, so the user never sees an empty screen during the brief loading window. The restoreState call should be wrapped in a try catch block, because a corrupted saved state, or one coming from an incompatible earlier app version, can otherwise throw an unhandled error that blocks the app from starting at all.

It also helps to throttle writes to AsyncStorage instead of writing on every single navigation immediately, for example through simple debouncing with a 300 to 500 millisecond delay, because very frequent, fast successive navigations otherwise trigger unnecessary write operations to persistent storage and can cost noticeable performance, especially on older Android devices with slower internal storage.

4. The Expo Router approach: URL instead of a state object

Expo Router takes a conceptually different approach from the classic React Navigation persistence API, since every route there already corresponds to a URL and a large part of the navigation state can be derived directly from the current path. Instead of a complex, nested state object, it is often enough to simply save the last visited path and have the app navigate straight to that path through router.replace on restart, letting Expo Router reconstruct most of the matching stack history on its own.

For cases where the full stack history also needs to survive, such as several push steps built on top of each other inside one tab, the same onStateChange and initialState mechanism from React Navigation still applies, since Expo Router builds on React Navigation internally and uses the same NavigationContainer. In practice, though, the simpler, URL based restoration of the last path is fully sufficient for most apps and avoids the complexity of full state serialization.

5. Don't forget to version the saved state

A saved navigation state references concrete route names and screen parameters, which can change with every app update, for example when a screen gets renamed, a parameter gets restructured, or an entire route gets removed. If such a stale state gets fed back in unchanged through initialState after an update, React Navigation may try to render a route that no longer exists, which in the best case causes a crash and in the worse case leads to a silent, confusing broken state.

The most reliable safeguard against that is a version suffix in the AsyncStorage key itself, as shown above with NAVIGATION_STATE_V3, deliberately bumped whenever a structural change happens to the navigation structure. Once the key changes, the app finds no saved state under the new name on next launch, silently discards the stale entry, and starts cleanly at the configured start route instead, without needing any structural comparison logic to be written.

6. When state should deliberately not be restored

Not every situation benefits from restoring navigation state, and a login screen is the clearest example: if the saved session has expired by the time the app restarts, it would be wrong to first send the user back to a deeply nested, actually protected screen, only to redirect them straight back to login from there. An auth guard that checks the current session status before any state restoration happens, and discards the saved state entirely when authentication is missing, prevents that brief but confusing flash of protected content.

Similarly problematic are one time or time sensitive flows such as a payment process, a multi step onboarding, or a confirmation dialog following an already completed action, where restoring midway through the process could lead to inconsistent or duplicate actions. For such screens, it is worth explicitly excluding them from persistence, either by deliberately not updating the saved state before entering these screens or by actively clearing it when the flow finishes.

7. Selective persistence: excluding individual screens on purpose

Instead of saving the entire navigation state unconditionally, the onStateChange handler can be extended to inspect the received state before writing it, filtering out specific route names or replacing them with a safe fallback route. If the current state, for example, contains a route named Checkout or PaymentConfirmation, a reduced state pointing at the parent, non critical route gets saved instead of the actual state, so a restart during a payment process does not attempt to resume that exact process halfway through.

The same filtering logic also suits short lived modal screens, such as a one time permission dialog or a review prompt, which no longer make sense after a restart because their original trigger no longer exists in the current context. A clear, documented list of routes excluded from persistence, kept right in the code, helps the whole team decide deliberately for new screens whether they belong to the restorable category or the deliberately transient one.

When an app is not simply reopened but launched through a deep link, for example from a push notification or a shared product link, that deep link should generally take priority over the saved navigation state, because it expresses the more recent, deliberate user intent. The practical solution is to first check on app launch whether a linking URL is present, and only feed in the saved state through initialState when no such link exists.

This prioritization prevents a confusing situation where a user taps a notification but lands on a completely different screen than the one the notification actually pointed to, because of a restored, older navigation state. In practice this check fits cleanly into the same restoreState function that loads the AsyncStorage state, by evaluating Linking.getInitialURL before the actual state restore runs.

9. Testing strategy for force quit scenarios

Navigation state persistence is hard to cover meaningfully with classic component tests, because the actual effect only becomes visible through a real process restart, which is why manual testing on physical devices and simulators should stay a fixed part of the testing routine. A repeatable manual test case deliberately navigates several levels deep into the app, fully terminates the process through the app switcher, and reopens the app afterward to check whether the exact same screen with the exact same parameters reappears.

It is also worth deliberately testing the expired login case during a force quit, by invalidating the session server side or through a manually tampered token beforehand, to confirm the auth guard actually discards the saved state instead of briefly flashing a protected screen. For automated end to end tests with Detox or Maestro, an app restart step can additionally be added to the test suite to keep the basic restoration mechanism guarded against regressions on a regular basis.

Approach Storage location Granularity Typical use
React Navigation Persistence API AsyncStorage Full nested state Classic stack or tab apps using React Navigation
Expo Router URL persistence AsyncStorage (path only) Last visited path File based routing, simple restoration
Selective filtering AsyncStorage with preprocessing Specific routes excluded Checkout, payment and onboarding flows
Versioned key AsyncStorage with key suffix Entire saved state Guarding against structural app updates
Deep link priority No extra storage Single target path Push notifications, shared links

Mironsoft

React Native app development and Magento integration

A mobile app for the Magento shop that actually runs smoothly?

We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.

App Concept

Plan the architecture and feature scope of a Magento-connected app together.

Magento API Integration

Cleanly connect product catalog, cart, and checkout to the shop API.

Store Publishing

Guide the App Store and Google Play release process without pitfalls.

10. Summary

Navigation State Persistence: Key Takeaways

Core mechanism

onStateChange writes the navigation state, initialState reads it back in on the next launch before the first render.

Expo Router

Usually saves only the last visited path instead of a complex state object and reconstructs history from it.

Limits

Login screens, payment and onboarding flows should be deliberately excluded from restoration.

Priority

A deep link on cold start should always take priority over a restored older navigation state.

11. FAQ: Navigation State Persistence: Key Takeaways

1Why does navigation state get lost by default on a force quit?
React Navigation and Expo Router keep the current screen and stack history exclusively as a JavaScript object in the running app instance's memory. Once the native process terminates, that object disappears entirely and navigation restarts at the configured start route on the next launch.
2Which props enable navigation state persistence in React Navigation?
The onStateChange and initialState props on NavigationContainer. onStateChange reports the new state as a serializable object on every navigation, initialState picks up a previously saved state on the next launch to resume exactly where things left off.
3Why does the NavigationContainer need to be held back on startup?
Because reading from AsyncStorage happens asynchronously and initialState is only evaluated on the very first render. Without a short delay through an isReady flag, the container would already start with the default state before the saved state has even loaded.
4How does the Expo Router approach differ from classic React Navigation persistence?
Since every route in Expo Router already corresponds to a URL, saving just the last visited path is usually enough instead of a full, nested state object. The app simply navigates to that path through router.replace on restart and reconstructs most of the history on its own.
5Why should saved state be versioned?
Because route names and screen parameters can change with app updates. A version suffix in the AsyncStorage key ensures a stale state that no longer structurally matches gets silently discarded after an update, instead of causing a crash or broken state.
6When should navigation state deliberately not be restored?
Above all with expired login sessions, where an auth guard should discard the saved state before any protected content briefly becomes visible. One time or time sensitive flows like payments or multi step onboarding also do not benefit from restoration.
7How can individual screens be excluded from persistence on purpose?
The onStateChange handler can inspect the received state before writing it, filter out specific route names such as Checkout or PaymentConfirmation, and save a parent, non critical route instead of the actual, critical state.
8Why should a deep link take priority over the saved navigation state?
Because a deep link, for example from a push notification, expresses the more recent, deliberate user intent. Without that prioritization, a user could tap a notification and still end up on a completely different, restored screen.
9How is navigation state persistence tested reliably?
Mostly manually, since the effect only shows up through a real process restart: navigate several levels deep, fully terminate the process through the app switcher, and reopen it to check whether the same screen with the same parameters reappears.
10Should writes to AsyncStorage happen immediately on every navigation?
No, simple debouncing with a 300 to 500 millisecond delay noticeably reduces the number of writes during fast successive navigations and avoids noticeable performance loss, especially on older Android devices.