skip to content

In React Navigation 7, how do you persist and restore navigation state with onStateChange and initialState without breaking deep links?

level: seniorimportance: should knowfreq 27%

answer

  1. save in onStateChange
  2. restore before first render
  3. initialState skips linking at launch
  4. restore only without a launch URL
  5. serializable params, versioned key

basics

~20 s

Save the state from onStateChange and pass it back as initialState on launch, but only when no launch URL exists: in React Navigation 7, providing initialState makes the container skip deep-link handling on the initial render.

solid answer

~50 s

Persistence has two halves. `onStateChange` gives the full root state after each change, which I serialise and store. On launch I read it back before rendering the container, keep the container unrendered (or a splash) until that finishes, and pass the result as `initialState`. The trap is deep links: when `initialState` is provided, `NavigationContainer` does not handle a URL for the initial render. So the restore step first awaits `Linking.getInitialURL()` and only restores saved state if the app was not opened by a link; otherwise a customer tapping a shared sofa link lands back on yesterday's checkout. Params must be serialisable — React Navigation warns about functions or class instances in state because they break exactly this — and the storage key should carry a version so a state saved by an older build with different screens is discarded, not restored.

code

tsx · 42 lines
tsx
import * as React from 'react';
import { Linking } from 'react-native';
import { NavigationContainer, type InitialState } from '@react-navigation/native';
import { linking } from './linking';
import { RootStack } from './RootStack';
import { storage } from './storage';

const PERSISTENCE_KEY = 'nav-state-v3';

export function App() {
  const [isReady, setIsReady] = React.useState(false);
  const [initialState, setInitialState] = React.useState<InitialState | undefined>();

  React.useEffect(() => {
    const restore = async () => {
      try {
        const initialUrl = await Linking.getInitialURL();
        if (initialUrl == null) {
          const saved = await storage.getItem(PERSISTENCE_KEY);
          if (saved != null) setInitialState(JSON.parse(saved));
        }
      } catch {
        setInitialState(undefined);
      } finally {
        setIsReady(true);
      }
    };
    restore();
  }, []);

  if (!isReady) return null;

  return (
    <NavigationContainer
      linking={linking}
      initialState={initialState}
      onStateChange={(state) => storage.setItem(PERSISTENCE_KEY, JSON.stringify(state))}
    >
      <RootStack />
    </NavigationContainer>
  );
}

go deeper

for a junior

Know that onStateChange can save the navigation state and initialState can restore it when the container first renders.

for a middle

Explain restore-before-render, why initialState must be ready on the first render, and why params must be serialisable.

for a senior

Raise the conflict with cold-start deep links, gate restoration on getInitialURL, version the storage key and handle corrupt data.

for a principal

Decide whether whole-tree restoration is worth its coupling to the navigator structure, or whether persisting a few app-level facts serves users better.

## What persistence means here **Navigation state persistence** saves the whole navigation tree — which navigators, which routes, which params, which one is focused — and restores it on the next launch. It is popular during development (a reload keeps you on the screen you were working on) and occasionally in production (a furniture store customer returns to the product they were comparing). **React Navigation 7** gives you the two hooks it needs on `NavigationContainer`: - **`onStateChange(state)`** — called with the latest root state after every change (not for the initial state). - **`initialState`** — a state object the container starts from instead of computing one. ## The basic loop 1. In `onStateChange`, `JSON.stringify` the state and write it to storage under a key. 2. On launch, before rendering the container, read the key and `JSON.parse` it. 3. Render nothing, or a splash, until the read finishes — the container must receive `initialState` on its **first** render; changing it later has no effect. 4. Render `NavigationContainer` with `initialState` set to the parsed state, or `undefined` if there was none. ## The deep-link trap `NavigationContainer` documents it directly: if `initialState` is provided, **deep links or URLs won't be handled on the initial render**. The linking machinery is skipped for startup because you have already told the container where to start. So a naive restore breaks cold-start links: a customer taps a shared link to the oak sofa, and the app restores yesterday's cart instead. The fix is to decide *before* restoring: | Launch situation | What to do | |---|---| | App opened by a link (`Linking.getInitialURL()` returns a URL) | Skip restoring; let `linking` build the state | | Normal launch, saved state present | Restore it as `initialState` | | Normal launch, nothing saved or unreadable | Leave `initialState` undefined | Links that arrive **while the app runs** are unaffected — the URL subscription still works after start-up. If your `linking.getInitialURL` is overridden (for example to include notification taps), the restore decision should call the same logic, or it will restore over a notification-launched session. ## Making saved state safe to restore - **Serialisable params only.** Functions, class instances or `Date` objects in params do not survive `JSON.stringify`. React Navigation logs a development warning, "Non-serializable values were found in the navigation state", and names persistence as what it breaks. Pass ids, not objects. - **Version the key.** A state saved by an older build may name screens that were renamed or params whose shape changed. Put a version in the storage key (for example `nav-state-v3`) and bump it when the navigator tree changes, so stale state is ignored rather than half-restored. - **Handle corrupt data.** Wrap the parse in `try`/`catch` and fall back to `undefined`; always leave the loading state in a `finally`, so a bad write never leaves the app blank. - **Think about auth.** Restoring a signed-in stack for a signed-out user will not open protected screens if they are rendered conditionally, but the saved state may still hold personal params. Clear it on sign-out. ## When not to persist Many production apps persist only in development, or persist a small piece of app state (the last viewed `sku`) and navigate from it, instead of the whole tree. Whole-tree restoration can surprise users who expect a fresh start, and it couples stored data to the navigator structure. ## What a strong answer shows It describes save-in-`onStateChange`, restore-before-render via `initialState`, then immediately raises the deep-link conflict and the `getInitialURL` check, and finishes with serialisable params, a versioned key and error handling.

  • Why can passing a Product object as a param break persistence?
    The saved state goes through `JSON.stringify`, so methods, class instances and dates do not come back as they were. React Navigation warns about non-serialisable values in state for this reason. Pass the `sku` and load the product on the screen instead.
  • Can you set initialState after the container has rendered, once storage has been read?
    No. `initialState` is used for the container's first render only; later changes are ignored. That is why the restore step keeps the container unrendered, or shows a splash, until the stored state has been read.

saying these in an interview costs you the question

  • Deep links still override a restored initialState on launch.
  • initialState can be set after the first render once storage finishes reading.
  • Any object can go in params because persistence stores it as is.
  • A state saved by an older build always restores cleanly after an update.