Choosing Between native‑stack and JS stack in React Navigation per Navigator
Per‑navigator decision guide: when to use native‑stack vs JS stack, constraints, trade‑offs, and a mixed‑stack setup with validation steps.
29 Jul 2025, 08:46 UTC

Every stack navigator in a React Navigation app forces the same choice: @react-navigation/native-stack, which drives real native navigation primitives through react-native-screens, or @react-navigation/stack, which implements the whole stack in JavaScript. The decision matters because it changes performance under navigation, how much of the header you can customize, and which native dependencies your build must carry. Useful takeaway: default to native‑stack, and reach for the JS stack only when a flow genuinely needs fully custom headers or hand‑built transition animations.
The decision and its constraints
The choice is per navigator, not per app. A typical app can use native‑stack for its main flow and a JS stack for one branded onboarding flow that needs a custom animated header. Before choosing, pin down four constraints:
- Native module availability. native‑stack requires
react-native-screensto be installed and linked correctly. If the native module is missing or outdated, you get build or runtime errors — there is no silent JS fallback. - Existing peer dependencies. The JS stack leans on
react-native-gesture-handler(and, in some versions, Reanimated). If those are already integrated and version‑matched to your React Native release, the JS stack is cheap to add; if not, that integration cost is part of the decision. - Header customization needs. If every screen needs a fully custom React‑rendered header, native‑stack's native header will fight you. If most screens use standard headers, native‑stack wins.
- React Navigation major version. Option names and defaults differ between v6 and v7. Verify everything against the README and docs for the version you actually installed.
Comparison at a glance
| Aspect | native‑stack | stack (JS) |
|---|---|---|
| Performance | Native primitives; less JS‑thread work during push/pop | JS‑driven transitions; heavier on the JS thread |
| Header | Native header; custom views are more constrained | Fully custom React header, anything renderable |
| Animations | Preset platform transitions | Interpolation‑based custom transitions |
| Gestures | Native swipe‑back behavior | gesture‑handler–based, tunable |
| Look and feel | Platform‑authentic (differs on iOS vs Android) | Can be made identical cross‑platform |
Trade‑offs in practice
native‑stack moves transition and gesture work off the JS thread. On low‑end Android devices or in apps where the JS thread is busy (large lists, heavy state updates), this is the difference between smooth and stuttery navigation. You also get the transitions users expect from the platform for free.
The JS stack trades some of that performance for control. If your design system specifies a branded header with animated elements, or a transition that doesn't exist in the native presets, the JS stack's interpolation‑based cardStyleInterpolator and custom header render prop are the supported way to build it. The cost is real: more JS‑thread work per frame during navigation, and more native peers to keep version‑aligned.
One warning that bites teams during migration: header‑related options are not one‑to‑one between the packages. An options object that works in one navigator may be ignored or rejected in the other, so swapping stack types is never a drop‑in change.
Concrete setup
Install the core package plus the stack package(s) you need. Run this in your project root with your normal package manager (no elevated permissions needed):
npm install @react-navigation/native @react-navigation/native-stack
npm install react-native-screens react-native-safe-area-context
# Only if you also need the JS stack:
npm install @react-navigation/stack react-native-gesture-handler
Then define the navigator. This example mixes both: native‑stack for the main flow, JS stack for a flow with a custom transition:
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createStackNavigator, CardStyleInterpolators } from '@react-navigation/stack';
const Main = createNativeStackNavigator();
const Onboarding = createStackNavigator();
function OnboardingFlow() {
return (
<Onboarding.Navigator
screenOptions={{
cardStyleInterpolator: CardStyleInterpolators.forFadeFromBottomAndroid,
}}
>
<Onboarding.Screen name="Welcome" component={WelcomeScreen} />
<Onboarding.Screen name="Permissions" component={PermissionsScreen} />
</Onboarding.Navigator>
);
}
export default function App() {
return (
<NavigationContainer>
<Main.Navigator>
<Main.Screen name="Home" component={HomeScreen} />
<Main.Screen name="Details" component={DetailsScreen} />
<Main.Screen
name="Onboarding"
component={OnboardingFlow}
options={{ headerShown: false }}
/>
</Main.Navigator>
</NavigationContainer>
);
}
Keep option names scoped per package — headerShown exists in both, but many header and transition options do not, so don't share a screenOptions object across the two navigator types without checking each key.
Validating the choice
- Confirm installed versions with
npm ls @react-navigation/native-stack @react-navigation/stack react-native-screensand read the README for that major before relying on any option name. - Build and run on both iOS and Android. Push and pop several screens with each stack type and compare transitions, swipe‑back gestures, and header behavior side by side.
- Profile the JS thread during navigation using the performance panel in React Native DevTools. If the JS stack drops frames on your target devices and native‑stack doesn't, that settles the default.
- Exercise every deep‑link path and your TypeScript route param types against the chosen navigator — linking config and typed route options can differ subtly between the packages.
Limitations to keep in mind
Everything above reflects well‑established behavior, but option names, defaults, and peer‑dependency requirements shift between React Navigation majors (v6 vs v7) and React Native releases. Treat the comparison as stable and the exact option keys as version‑dependent. If react-native-screens fails to build in your environment (old RN version, unconventional monorepo), native‑stack is off the table until that's fixed — the JS stack becomes the fallback, not the preference.
Validate your setup by running the same flow with each stack type on both platforms, checking header rendering, transition smoothness, and JS‑thread profiles. The concrete steps above give you a repeatable path to decide which navigator best fits each part of your app.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.