#50282·expo

[Router] Android: "state update on a component that hasn't mounted yet" from useLinking getInitialURL().then → setLastUnhandledLink

Author: msyntheseCreated Sep 17, 2026Updated Sep 17, 2026
Labelsneeds reviewcontributor: external

Minimal reproducible example

https://github.com/msynthese/expo-router-unmounted-setstate

Untouched npx create-expo-app@latest --template default (Expo SDK 57, expo-router 57.0.21) plus a README. Because this is a race (details below), the warning is intermittent on the template (about 1 launch in 4 on a Pixel 8 / API 36 emulator) but systematic in a real app whose first screen is heavy (native map, Paper provider, clustering): 3 of 3 cold launches, including right after pm clear. The stacks below were captured in that app.

Steps to reproduce

Android only (development build, npm). Not reproducible on iOS.

npm install
npx expo run:android
# then cold-launch the app a few times and watch LogBox / logcat:
adb shell am force-stop com.anonymous.exporouterunmountedsetstate
adb shell am start -n com.anonymous.exporouterunmountedsetstate/.MainActivity
adb logcat -d | grep "hasn't mounted yet"

Expected: no warning.

Actual: LogBox error on launch

Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have
a side-effect in your render function that asynchronously tries to update the component. Move this work
to useEffect instead.

It happens both when the app is launched from the home screen and when it is launched with a URL (the dev client URL, or any deep link).

Where the state update comes from

Captured with a console.error interceptor calling React.captureOwnerStack(), symbolicated through Metro (/symbolicate):

owner stack:
    at ContextNavigator (node_modules/expo-router/build/ExpoRoot.js:135:122)
    at ExpoRoot         (node_modules/expo-router/build/ExpoRoot.js:85:34)
    at App

js stack:
    at dispatchSetState
    at anonymous (node_modules/expo-router/build/fork/useLinking.native.js:127:47)   <- url.then callback: onUnhandledLinking(...)
    at tryCallOne (InternalBytecode.js)

onUnhandledLinking is setLastUnhandledLink, the useState setter declared in fork/NavigationContainer.js:

const [lastUnhandledLink, setLastUnhandledLink] = React.useState();
const { getInitialState } = useLinking(refContainer, { ... }, setLastUnhandledLink);

Analysis

  1. NavigationContainer calls getInitialState() during its first render, from useThenable's useState(create) initializer.
  2. On Android, getInitialURLWithTimeout() always returns a promise (Promise.race([Linking.getInitialURL(), timeout])), and expo-router's own getInitialURL in link/linking.js wraps it again: Promise.resolve(...).then(url => parseExpoGoUrlFromListener(url) ?? getRootURL()). So the resolved value is always a string on Android, even for a home-screen launch (it becomes the root URL). On iOS the same function returns synchronously via ExpoLinking.getLinkingURL(), so there is no async gap and no warning.
  3. In fork/useLinking.native.js, getInitialState does:
    return url.then((url) => {
      const state = getStateFromURL(url);
      if (typeof url === 'string') {
        onUnhandledLinking(extractExpoPathFromURL(prefixes, url));   // line 127: setState
      }
      return state;
    });
    
    This callback runs whenever the native promise resolves, with no check that the component that created it has been committed.
  4. If the native round-trip completes before NavigationContainer's first render is committed (slow first frame, discarded/re-started render), React reports the update on a not-yet-mounted fiber. That is why it is timing-dependent, Android-only, and independent of whether a deep link was used.

Suggested fix

Either defer the report to an effect (the state setter is only meaningful once mounted anyway), e.g. keep the unhandled path in a ref inside getInitialState and flush it from a useEffect in useLinking, or guard the callback with a mounted ref set in an effect. Alternatively, resolve the initial URL before rendering NavigationContainer on Android, mirroring the synchronous iOS path.

Environment

  expo-env-info 2.1.0 environment info:
    System:
      OS: macOS 26.6.2
      Shell: 5.9 - /bin/zsh
    Binaries:
      Node: 25.1.0 - /opt/homebrew/bin/node
      npm: 11.6.2 - /opt/homebrew/bin/npm
    Managers:
      CocoaPods: 1.16.2 - /opt/homebrew/bin/pod
    SDKs:
      iOS SDK:
        Platforms: DriverKit 25.5, iOS 26.5, macOS 26.5, tvOS 26.5, visionOS 26.5, watchOS 26.5
    IDEs:
      Xcode: 26.6/17F113 - /usr/bin/xcodebuild
    npmPackages:
      expo: ^57.0.0 => 57.0.22
      expo-router: ~57.0.21 => 57.0.21
      react: 19.2.3 => 19.2.3
      react-dom: ^19.2.3 => 19.2.3
      react-native: 0.86.3 => 0.86.3
    Expo Workflow: bare

Android: emulator Pixel 8, API 36 (google_apis, arm64-v8a), Hermes, new architecture (default).

Expo Doctor Diagnostics

Running 21 checks on your project...
21/21 checks passed. No issues detected!