With React Navigation's native stack in React Native, how do you keep a heavy screen from mounting during its push transition?
answer
- first render happens as the push starts
- light shell first, heavy subtree later
- transitionEnd event on the native stack
- check e.data.closing is false
- return the unsubscribe from the effect
basics
~10 sRender a light shell first, then mount the expensive subtree after the native stack's transitionEnd event fires with data.closing false, subscribing in an effect and returning the unsubscribe function.
solid answer
~40 sWhen you push a screen, React renders it on the JS thread as the push begins, so a screen that mounts a long comment thread, a chart or a map all at once competes with the transition: the tap feels ignored or the slide hitches. Split the screen into a **cheap shell** (header, title, primary image, a placeholder) and the **heavy subtree**. In the screen, subscribe with `navigation.addListener('transitionEnd', e => ...)` inside `useEffect`, set a flag when `e.data.closing` is `false`, and render the heavy part only once the flag is set; return the unsubscribe function that `addListener` gives you. The native stack's event map defines both `transitionStart` and `transitionEnd` with a `closing` flag. Check it fires in every presentation you use, and keep the shell meaningful so users see progress immediately.
code
tsx · 42 linesimport { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
type RootStackParamList = {
Timeline: undefined;
PostDetail: { postId: string; author: string };
};
type Props = NativeStackScreenProps<RootStackParamList, 'PostDetail'>;
function CommentThread({ postId }: { postId: string }) {
// Stand-in for an expensive subtree: hundreds of comments, embeds, charts.
return <Text>Comments for {postId}</Text>;
}
export function PostDetailScreen({ navigation, route }: Props) {
const [transitionDone, setTransitionDone] = useState(false);
useEffect(() => {
// addListener returns its own unsubscribe function.
return navigation.addListener('transitionEnd', (e) => {
if (!e.data.closing) setTransitionDone(true);
});
}, [navigation]);
return (
<View style={styles.screen}>
<Text style={styles.author}>{route.params.author}</Text>
{transitionDone ? (
<CommentThread postId={route.params.postId} />
) : (
<ActivityIndicator style={styles.spinner} />
)}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16 },
author: { fontSize: 18, fontWeight: '600' },
spinner: { marginTop: 24 },
});go deeper
Know that a pushed screen renders as the push starts, so a heavy first render slows the navigation, and that you can show a light screen first.
Explain the shell-then-content pattern with the native stack's transitionEnd event, the closing flag and returning the unsubscribe from the effect.
Show you guard the deferral: every presentation emits the event, the shell is useful, data work is moved too, and the result is checked on a release build.
Weigh screen-level deferral against broader fixes such as lighter screen designs, server-side preparation of data and navigation conventions shared across teams.
## What goes wrong on a push On a timeline, tapping a post pushes a **PostDetail** screen. React renders that screen on the **JS thread** as the push begins, and the resulting views then have to be created on the native side before they can be shown. If the screen's first render includes everything - hundreds of comments, embedded media, a map or a chart - that one render is large, and it lands exactly when the user is waiting for motion: - the tap can feel ignored because the push has to wait for or compete with the render; - JavaScript stays busy while the slide runs, so taps on the new screen lag; - a very large first render can make the transition hitch. With `@react-navigation/native-stack` the slide itself is a native animation, which is why the first symptom is usually latency or a blank-then-full screen rather than a stuttering slide. Either way, the cure is to make the **first render small**. ## The pattern: shell first, heavy content after the transition Split the screen in two: | Part | Contents | When it renders | |---|---|---| | Shell | header, title, author, primary image, placeholders | immediately, as part of the push | | Heavy subtree | comment thread, related posts, charts, maps | after the opening transition ends | React Navigation's native stack emits **transition events** to the screen being animated. Its event map defines: - `transitionStart` with `data: { closing: boolean }` - the animation started; - `transitionEnd` with `data: { closing: boolean }` - the animation finished. `closing` is `false` when the screen is being opened and `true` when it is being closed, so the screen checks for `closing === false` before revealing its heavy content. ## Wiring it correctly 1. Keep a boolean state such as `transitionDone`, initially `false`. 2. In `useEffect`, call `navigation.addListener('transitionEnd', callback)`. 3. In the callback, set the flag when `e.data.closing` is `false`. 4. **Return the unsubscribe function** that `addListener` returns, so the listener goes away with the effect. 5. Render the shell unconditionally and the heavy subtree behind the flag, with a placeholder in its place. In an Expo Router app the same event is available: Expo Router ships its own copy of React Navigation's native stack, which emits `transitionEnd` with the same `closing` flag, and since SDK 56 Expo apps import React Navigation APIs through `expo-router` rather than from `@react-navigation/*` packages. ## Making the deferral safe Deferring rendering adds a state the screen did not have before, so guard it: - **Check that the event fires** in every presentation you use (card push, modal, a disabled animation). If a path never emits it, the heavy content would never appear; add a fallback for that path. - **Keep the shell useful**: real title and image rather than a bare spinner, so the screen feels ready. - **Do not defer what the user needs first**: if the comment box is the primary action, it belongs in the shell. - **Mind back navigation**: if the user swipes back before the transition finishes, the flag never needs to flip; the unsubscribe in cleanup removes the listener. - **Split very heavy subtrees further** - for example, render the first comments and let the list render the rest as it scrolls. ## Pitfalls - Using `setTimeout` with a guessed duration; it is wrong on slow devices and wasteful on fast ones. - Forgetting the `closing` check, which can flip the flag while a screen is being dismissed. - Not returning the unsubscribe function, leaving the listener attached after the effect is gone. - Deferring rendering but keeping expensive **data work** in the first render; parse and derive data outside the transition too. - Deferring everything behind a spinner, which turns a short hitch into a longer, emptier wait. The interview point is that navigation performance is often about **when** a screen renders, not only **how fast**: a small first render keeps the push crisp, and the transition event tells you when it is safe to do the rest.
- Why check e.data.closing inside the transitionEnd listener?The native stack emits `transitionEnd` for both opening and closing animations of a screen. `closing` is `false` when the screen was being shown, which is the case where heavy content should appear; reacting to a closing transition would do work for a screen that is leaving.
- What is the risk of deferring content behind transitionEnd, and how do you guard against it?If some presentation never emits the event - for example a path with the animation disabled - the heavy content never mounts. Test every way the screen is opened and add a fallback for any path that does not emit it, while keeping the shell meaningful on its own.
saying these in an interview costs you the question
- A native-stack screen renders only after its transition finishes
- Deferring with a fixed setTimeout is as good as the transition event
- transitionEnd fires only when a screen is opened, never on close
- addListener returns nothing, so an effect has nothing to clean up
- Deferring rendering alone fixes heavy data parsing in the first render