[Router] Android: "state update on a component that hasn't mounted yet" from useLinking getInitialURL().then → setLastUnhandledLink
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
NavigationContainercallsgetInitialState()during its first render, fromuseThenable'suseState(create)initializer.- On Android,
getInitialURLWithTimeout()always returns a promise (Promise.race([Linking.getInitialURL(), timeout])), and expo-router's owngetInitialURLinlink/linking.jswraps 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 viaExpoLinking.getLinkingURL(), so there is no async gap and no warning. - In
fork/useLinking.native.js,getInitialStatedoes:
This callback runs whenever the native promise resolves, with no check that the component that created it has been committed.return url.then((url) => { const state = getStateFromURL(url); if (typeof url === 'string') { onUnhandledLinking(extractExpoPathFromURL(prefixes, url)); // line 127: setState } return state; }); - 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!
Source: expo/expo