React DevTools Profiler
Finding avoidable re-renders on navigation transitions - the cookbook workflow for Expo SDK 57 apps before you rewrite screens or add memo everywhere.
Search across all documentation pages
Finding avoidable re-renders on navigation transitions - the cookbook workflow for Expo SDK 57 apps before you rewrite screens or add memo everywhere.
Quick-reference recipe card - copy-paste ready.
Setup (once per session)
# Start app with dev client or debug build
npx expo start
# Press j in terminal → open React Native DevTools in browser
# Open Profiler tab → gear → enable "Record why each component rendered"Repro script - navigation transition
Stable navigation shell pattern
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { useMemo } from "react";
export default function TabLayout() {
const screenOptions = useMemo(
() => ({
headerShown: false,
tabBarActiveTintColor: "#2563eb",
}),
[],
);
return (
<Tabs screenOptions={screenOptions}>
<Tabs.Screen name="index" options={{ title: "Home" }} />
<Tabs.Screen name="search" options={{ title: "Search" }} />
</Tabs>
);
}// src/ui/FeedRow.tsx - memo only after parent props are stable
import { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
export type FeedItem = { id: string; title: string };
export const FeedRow = memo(function FeedRow({
item,
onPress,
}: {
item: FeedItem;
onPress: (id: string) => void;
}) {
return (
<Pressable onPress={() => onPress(item.id)} style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
</Pressable>
);
});
const styles = StyleSheet.create({
row: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: "#e2e8f0" },
title: { fontSize: 16, fontWeight: "600" },
});When to reach for this:
React.memo to 40 components without evidence.A minimal repro: unstable screenOptions and a new renderItem each render force the entire feed to commit on every tab blur/focus.
import { memo, useCallback, useMemo, useState } from "react";
import {
FlatList,
ListRenderItem,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
type Post = { id: string; title: string };
const POSTS: Post[] = Array.from({ length: 120 }, (_, i) => ({
id: String(i),
title: `Post ${i + 1}`,
}));
const PostRow = memo(function PostRow({
post,
onOpen,
}: {
post: Post;
onOpen: (id: string) => void;
}) {
return (
<Pressable onPress={() => onOpen(post.id)} style={styles.row}>
<Text style={styles.title}>{post.title}</Text>
</Pressable>
);
});
function Feed({ posts }: { posts: Post[] }) {
const [opened, setOpened] = useState<string | null>(null);
const onOpen = useCallback((id: string) => setOpened(id), []);
const renderItem: ListRenderItem<Post> = useCallback(
({ item }) => <PostRow post={item} onOpen={onOpen} />,
[onOpen],
);
return (
<View style={styles.panel}>
<Text style={styles.heading}>Feed {opened ? `(opened ${opened})` : ""}</Text>
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={renderItem}
extraData={opened}
style={styles.list}
/>
</View>
);
}
/** Simulates tab shell remounting with fresh object literals - Profiler will light up. */
function UnstableShell({ children }: { children: React.ReactNode }) {
const [tab, setTab] = useState<"home" | "search">("home");
// BAD: new object every render - triggers navigation subtree commits
const headerStyle = { backgroundColor: tab === "home" ? "#eff6ff" : "#f8fafc" };
return (
<View style={styles.shell}>
<View style={[styles.header, headerStyle]}>
<Pressable onPress={() => setTab("home")}>
<Text>Home</Text>
</Pressable>
<Pressable onPress={() => setTab("search")}>
<Text>Search</Text>
</Pressable>
</View>
{tab === "home" ? children : <Text style={styles.placeholder}>Search placeholder</Text>}
</View>
);
}
export default function ProfilerNavigationDemo() {
const posts = useMemo(() => POSTS, []);
return (
<UnstableShell>
<Feed posts={posts} />
</UnstableShell>
);
}
const styles = StyleSheet.create({
shell: { flex: 1 },
header: { flexDirection: "row", gap: 16, padding: 16 },
panel: { flex: 1 },
heading: { paddingHorizontal: 16, fontWeight: "700" },
list: { flex: 1 },
row: { padding: 16 },
title: { fontSize: 15 },
placeholder: { padding: 16, color: "#64748b" },
});Profiler checklist for this screen
| Symptom in recording | Likely cause | Fix |
|---|---|---|
Every PostRow commits on tab tap | Parent Feed re-rendered; unstable renderItem or missing memo | useCallback renderItem + memo row |
UnstableShell commits all children | Inline headerStyle object | useMemo or StyleSheet |
Rows commit when only opened changes | Expected - pass extraData | Ensure only affected rows need full tree work |
| Provider at root commits on navigation | Context value recreated | Split context; memoize value object |
// BAD - new value every render
<AppContext.Provider value={{ theme, setTheme, cart, setCart }}>
// BETTER - split read/write or memoize value slices
const value = useMemo(() => ({ theme, cart }), [theme, cart]);
<AppContext.Provider value={value}>Navigation often reads context in tab bars and headers. If the value object is fresh each render, every screen under the provider commits.
_layout.tsx files re-render when route segments change. Hoist heavy providers below the segment that changes, or split layouts so stack modals do not wrap the entire tab tree.
// app/(tabs)/_layout.tsx - keep providers that do not need stack state here
// app/(tabs)/feed/_layout.tsx - stack-specific state stays in feed subtreeWhen a stack screen opens over a tab, the tab may stay mounted. If the feed parent subscribes to navigation focus events and calls setState on every blur, Profiler shows a spike on all visible rows. Debounce focus handlers and avoid storing navigation objects in React state.
| View | Use for |
|---|---|
| Flame graph | Finding which parent dragged children into a commit |
| Ranked | Total time per component - start here for "who is slow" |
| Timeline | Correlating commits with gestures (scroll, tab switch) |
React 19 "Why did this render?" categories:
memo after parent is stable.renderItem is a new function reference.Expo SDK 57 routes through React Native DevTools (press j in CLI). Use the embedded Profiler tab - the workflow matches React web DevTools for commits and ranked views.
DevTools attaches to debuggable builds. For release-like timing, use Hermes sampling and Flashlight alongside Profiler - see Hermes Sampling Profiler and Flashlight Benchmarking.
Only after renderItem, handlers, and parent context are stable. Otherwise Profiler shows rows committing anyway - see List Performance Tuning.
Some re-render on focus is expected. Spikes that include the entire feed are not - stabilize screenOptions, tabBarStyle, and providers in app/(tabs)/_layout.tsx.
More automatic batching reduces duplicate commits from rapid setState - good. It does not fix unstable props from parents; referential equality rules are unchanged.
No universal ms threshold in Profiler - look for unexpected commits (all rows on modal open) and ranked outliers you can eliminate. Pair with FPS measurement on device for user-visible impact.
memo, extraData, stable renderItemlazy tabs and mount behaviorStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026