skip to content

With React Navigation's native stack in React Native, how do you keep a heavy screen from mounting during its push transition?

level: middleimportance: should knowfreq 40%

answer

  1. first render happens as the push starts
  2. light shell first, heavy subtree later
  3. transitionEnd event on the native stack
  4. check e.data.closing is false
  5. return the unsubscribe from the effect

basics

~10 s

Render 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 s

When 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 lines
tsx
import { 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

for a junior

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.

for a middle

Explain the shell-then-content pattern with the native stack's transitionEnd event, the closing flag and returning the unsubscribe from the effect.

for a senior

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.

for a principal

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