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.
Table of Contents
- 1. Why navigation state gets lost by default
- 2. React Navigation's state persistence API
- 3. Concrete implementation step by step
- 4. The Expo Router approach: URL instead of a state object
- 5. Don't forget to version the saved state
- 6. When state should deliberately not be restored
- 7. Selective persistence: excluding individual screens on purpose
- 8. Combining persistence with deep link priority on cold start
- 9. Testing strategy for force quit scenarios
- 10. Summary
- 11. FAQ
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.
8. Combining persistence with deep link priority on cold start
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.