React Native Was Slow Until I Fixed This

React Native Was Slow Until I Fixed This

My game was lagging. Animations were stuttering. The profiler showed re-renders everywhere. Here's what I learned building Idle Rampage.

The symptom

The game runs a tick every 100ms to update resources. Every tick, the UI would visibly stutter. Scrolling was janky. Tapping buttons felt sluggish. The whole thing felt like it was running on a 2012 phone.

I was running on a brand new iPhone. Something was very wrong.

Why 60fps matters on mobile

Mobile users expect buttery smooth animations. That means 60 frames per second, which gives you about 16ms per frame. If your JavaScript thread is busy re-rendering 50 components, you're going to blow past that budget and drop frames.

Dropped frames feel sluggish. Your app feels like it's lagging behind the user's finger. It's the difference between an app that feels native and one that feels like a janky web wrapper.

The diagnosis: React DevTools profiler

If you're not using the React DevTools profiler, start now. It shows you exactly which components are re-rendering and why.

Here's my workflow: I start a profiler recording, interact with the app for a few seconds, then stop. The profiler shows each render as a flame graph. The wider the bar, the longer that component took to render. Gray bars are components that didn't re-render.

In my case, the profiler showed a horror show. Every single component in the entire app was re-rendering on every tick. 50+ components, 10 times per second. No wonder it was slow.

The profiler also shows why each component rendered. Click on any component and it'll tell you whether it rendered because of a state change, prop change, or parent re-render. This is gold for debugging.

Why was everything re-rendering?

React re-renders a component when its parent re-renders, unless you tell it not to. My state was at the top level (in Zustand), and when it changed, everything downstream got the memo to re-render.

The components themselves weren't doing anything wrong. They were just following React's default behavior.

The fix: React.memo

React.memo is a higher-order component that tells React 'only re-render this if the props actually changed':

const BuildingCard = React.memo(({ building, onUpgrade }) => {
  return (
    <View style={styles.card}>
      <Text>{building.name}</Text>
      <Text>{building.level}</Text>
      <Button onPress={onUpgrade} title='Upgrade' />
    </View>
  );
});

With this wrapper, React will shallow-compare the props before re-rendering. If building and onUpgrade are the same objects as last time, it skips the re-render entirely.

The gotcha: reference equality

Here's where it gets tricky. React.memo uses === to compare props. So if you're creating new objects or functions on every render, memo won't help:

// This breaks memoization - new object every render
<BuildingCard building={{ ...building, extra: 'data' }} />

// This also breaks it - new function every render
<BuildingCard onUpgrade={() => upgrade(building.id)} />

useMemo for objects

If you need to compute or transform data, wrap it in useMemo:

const enrichedBuilding = useMemo(() => ({
  ...building,
  canUpgrade: gold >= building.upgradeCost,
}), [building, gold]);

<BuildingCard building={enrichedBuilding} />

Now enrichedBuilding only gets a new reference when building or gold changes. The memo actually works.

useCallback for functions

Same idea for event handlers:

// Bad: creates a new function every render
<BuildingCard onUpgrade={() => upgrade(building.id)} />

// Good: stable function reference
const handleUpgrade = useCallback(() => {
  upgrade(building.id);
}, [building.id]);

<BuildingCard onUpgrade={handleUpgrade} />

The dependency array is important. If building.id changes, you want a new function. But if it's the same, you want the same function reference.

Zustand selectors: only subscribe to what you need

I use Zustand for state management, and one killer feature is selectors. Instead of subscribing to the entire store, you can subscribe to just the slice you care about:

// Bad: re-renders whenever ANY store value changes
const { gold, buildings, upgrades } = useStore();

// Good: only re-renders when gold changes
const gold = useStore(state => state.gold);

This was huge for my game. I had components that only cared about gold, but they were re-rendering every time anything in the store changed. With selectors, they only re-render when gold actually updates.

You can even use shallow equality for objects:

const buildings = useStore(
  state => state.buildings,
  shallow  // only re-render if the array reference changes
);

Extracting expensive components

Sometimes you have a component that's expensive to render. Maybe it does complex calculations or renders a lot of nested elements. If that component is re-rendering unnecessarily, it can tank your performance.

I had a stats panel that calculated all sorts of derived values (damage per second, gold per second, efficiency ratings, etc.). It was re-rendering on every tick because it was inside a parent component that subscribed to the whole store.

The fix was to extract it into its own component and have it subscribe only to the stats it needed. Now it only re-renders when those specific values change, not on every tick.

FlashList vs FlatList for long lists

If you're rendering a list of items, use FlashList from Shopify instead of FlatList. It's a drop-in replacement that's significantly faster, especially for long lists.

FlatList has some performance quirks around blank spaces and janky scrolling. FlashList fixes these by using a different recycling algorithm. In my tests, scrolling through a list of 100+ items went from choppy to perfectly smooth just by swapping the component.

// Before
import { FlatList } from 'react-native';

// After
import { FlashList } from '@shopify/flash-list';

The API is nearly identical. You just need to provide estimatedItemSize instead of getItemLayout.

useDeferredValue for non-urgent updates

Sometimes you have updates that don't need to be immediate. Search filtering is a classic example. You want the typing to feel instant, but the filtered results can lag slightly behind.

const [searchText, setSearchText] = useState('');
const deferredSearchText = useDeferredValue(searchText);

// The input updates immediately
<TextInput value={searchText} onChangeText={setSearchText} />

// But the expensive filtering uses the deferred value
const filteredBuildings = buildings.filter(b =>
  b.name.toLowerCase().includes(deferredSearchText.toLowerCase())
);

React will prioritize updating the input (urgent) and defer the filtering (non-urgent). The user never sees a laggy input, but the results might be a frame or two behind. In practice, you can't even tell.

InteractionManager for heavy computations

If you need to do something expensive after a user interaction (like navigating to a new screen), use InteractionManager:

InteractionManager.runAfterInteractions(() => {
  // This runs after animations complete
  performExpensiveCalculation();
});

This ensures your animations finish smoothly before you start the heavy work. Without it, the navigation animation might stutter as React tries to do everything at once.

Common re-render patterns to avoid

After profiling dozens of screens, I've noticed some patterns that always cause problems:

  1. Inline object props: <Component style={{ margin: 10 }} /> creates a new object every render. Move it outside the component or use useMemo.

  2. Array methods in render: buildings.map(...) is fine, but buildings.filter(...).map(...) creates a new array every render. Use useMemo for the filtering.

  3. Context changes: If your context value changes, every consumer re-renders. Split contexts by update frequency.

  4. Derived state: Don't store computed values in state. Use useMemo instead.

The results

After wrapping my components in React.memo and fixing the callback/memo issues, the profiler showed a completely different picture. On a typical tick, maybe 2-3 components would re-render instead of 50+.

The stutter was gone. Scrolling was smooth. Taps felt instant. The app went from feeling broken to feeling native.

When NOT to use React.memo

Don't just wrap everything in React.memo by default. There's overhead to the comparison. If a component almost always re-renders anyway (like a component that displays frequently-changing data), the memo comparison is wasted work.

Use the profiler to identify which components are re-rendering unnecessarily, then apply memo strategically.

Real-world example: optimizing a game inventory screen

Let me walk you through a concrete example from Idle Rampage. I had an inventory screen showing 50+ buildings, each with stats, upgrade buttons, and progress bars. Every 100ms tick, the entire screen would re-render. Here's how I fixed it.

Step 1: Identify the problem

The profiler showed every BuildingCard component re-rendering, even though only one or two buildings actually changed per tick. That's 48+ wasted re-renders.

Step 2: Wrap components strategically

I wrapped BuildingCard in React.memo, but it kept re-rendering. Why? I was passing inline styles and new function instances:

// Before: re-renders every time
<BuildingCard
  building={building}
  style={{ opacity: building.locked ? 0.5 : 1 }}
  onUpgrade={() => handleUpgrade(building.id)}
/>

Step 3: Stabilize the props

I moved styles to StyleSheet (created once) and used useCallback for handlers:

const handleUpgrade = useCallback(() => {
  upgradeBuilding(building.id);
}, [building.id]);

<BuildingCard
  building={building}
  style={building.locked ? styles.locked : styles.unlocked}
  onUpgrade={handleUpgrade}
/>

Now BuildingCard only re-renders when the building data actually changes. The screen went from 50 re-renders per tick to 1-2.

The cascade problem: when one re-render triggers twenty

Here's a pattern I see all the time. You have a tiny state update, but it causes a cascade of re-renders through your component tree.

function GameScreen() {
  const store = useStore(); // subscribes to entire store

  return (
    <View>
      <Header gold={store.gold} />
      <BuildingsList buildings={store.buildings} />
      <StatsPanel stats={store.stats} />
    </View>
  );
}

When anything in the store changes, GameScreen re-renders. When GameScreen re-renders, all its children re-render, even if their specific props haven't changed.

The fix is to move subscriptions down the tree:

function GameScreen() {
  // No subscription here
  return (
    <View>
      <Header /> {/* subscribes to gold internally */}
      <BuildingsList /> {/* subscribes to buildings internally */}
      <StatsPanel /> {/* subscribes to stats internally */}
    </View>
  );
}

function Header() {
  const gold = useStore(state => state.gold);
  return <Text>{gold} gold</Text>;
}

Now each component subscribes to exactly what it needs. A gold update only re-renders Header, not the entire screen.

Zustand advanced: custom equality functions

Sometimes the shallow equality check isn't enough. Maybe you have an array of objects, and you only care if a specific property changed.

Zustand lets you provide a custom equality function:

const buildings = useStore(
  state => state.buildings,
  (oldBuildings, newBuildings) => {
    // Only re-render if building levels changed
    return oldBuildings.every((old, i) =>
      old.level === newBuildings[i]?.level
    );
  }
);

This is powerful but dangerous. Get it wrong and your component won't update when it should. I only use custom equality for very specific cases where I've profiled and confirmed it's necessary.

Context performance pitfalls

Context is convenient, but it's a performance footgun. Every time the context value changes, every component that uses useContext re-renders.

I made this mistake with a ThemeContext:

function App() {
  const [theme, setTheme] = useState('dark');
  const [user, setUser] = useState(null);

  // This object is recreated every render
  const value = { theme, setTheme, user, setUser };

  return (
    <AppContext.Provider value={value}>
      <GameScreen />
    </AppContext.Provider>
  );
}

Every render, value is a new object, so every consumer re-renders. Even components that only care about theme re-render when user changes.

Fix 1: Memoize the value

const value = useMemo(
  () => ({ theme, setTheme, user, setUser }),
  [theme, user]
);

Fix 2: Split the contexts

<ThemeContext.Provider value={themeValue}>
  <UserContext.Provider value={userValue}>
    <GameScreen />
  </UserContext.Provider>
</ThemeContext.Provider>

Now theme updates only affect components that use ThemeContext.

Fix 3: Use a proper state management library

Honestly, for anything beyond simple theme/auth data, use Zustand or Redux. They're designed for performance.

Measuring performance: it's not just about re-renders

Re-renders are the most common problem, but they're not the only one. Sometimes a component re-renders appropriately, but the render itself is slow.

The React DevTools profiler shows render duration. If you see a component taking 50ms to render, that's your problem, even if it only renders once.

Common causes of slow renders:

  1. Heavy computation in the component body: Move it to useMemo
  2. Rendering huge lists without virtualization: Use FlashList
  3. Complex style calculations: Pre-compute or memoize them
  4. Large images without optimization: Resize and compress your assets

I had a stats panel calculating dozens of derived values on every render. The profiler showed it taking 30ms per render. Wrapping those calculations in useMemo dropped render time to 2ms—the calculations now only run when the data actually changes.

FlatList optimization deep dive

FlatList is the most common performance bottleneck I see in React Native apps. Here's how to use it properly:

1. Provide a key extractor

<FlatList
  data={buildings}
  keyExtractor={item => item.id} // Don't use index!
  renderItem={({ item }) => <BuildingCard building={item} />}
/>

Using index as key is tempting but wrong. When items reorder, React thinks they're different items and re-creates them.

2. Use getItemLayout if possible

If all items are the same height, provide getItemLayout:

<FlatList
  data={buildings}
  getItemLayout={(data, index) => ({
    length: ITEM_HEIGHT,
    offset: ITEM_HEIGHT * index,
    index,
  })}
/>

This lets FlatList skip measuring items, which dramatically improves scroll performance.

3. Set appropriate window size

<FlatList
  initialNumToRender={10}
  maxToRenderPerBatch={10}
  windowSize={5}
/>

These control how many items are rendered off-screen. Smaller values use less memory but can show blank spaces during fast scrolling. Tune these based on your item size and target devices.

4. Or just use FlashList

Honestly, after fighting with FlatList performance for weeks, I switched to FlashList and most problems disappeared. It handles variable-height items better, scrolls smoother, and requires less tuning.

The nuclear option: shouldComponentUpdate

If you're working with class components (legacy codebases), you can't use React.memo. Instead, implement shouldComponentUpdate to manually control re-renders. This gives you complete control but it's verbose and error-prone—if you forget to check a prop, your component won't update when it should.

Animation performance: 60fps or bust

Animations deserve special attention. A janky animation ruins the user experience more than almost anything else.

Use the native driver

Always enable the native driver for animations:

Animated.timing(fadeAnim, {
  toValue: 1,
  duration: 300,
  useNativeDriver: true, // This is crucial
}).start();

With useNativeDriver, animations run on the native thread at 60fps, even if your JavaScript thread is busy. Without it, animations can stutter during re-renders or heavy computation.

Avoid animating layout properties

The native driver only supports transform and opacity. If you try to animate width, height, or margins, you'll get an error. Instead, use scale transforms:

// Don't animate width
Animated.timing(width, { toValue: 200 })

// Do use scale
Animated.timing(scale, { toValue: 1.5 })

Reanimated for complex animations

For gesture-driven animations or complex choreography, use react-native-reanimated. It runs animations entirely on the UI thread, giving you native performance even for complex interactions.

I use Reanimated for all drag-and-drop interactions, carousel scrolling, and gesture-based navigation. The difference is night and day.

Debugging performance in production

The profiler only works in development mode. For production debugging, use React Native's built-in FPS monitor (shake device and select "Show Perf Monitor"), Flipper for detailed profiling, or add custom performance marks in critical paths to identify slow operations that don't show up in dev.

The deeper lesson

Don't guess at performance problems. Every time I've tried to optimize based on intuition, I've been wrong. The profiler tells you exactly what's happening. Use it first, then fix what it shows you.

React Native performance is usually about preventing unnecessary work, not about making necessary work faster. Most apps are slow because they're doing way more than they need to.

Performance optimization is a loop: profile, identify bottlenecks, fix them, profile again. Don't optimize everything up front. Wait until you have a problem, then use tools to find the exact cause.

And remember: premature optimization is real. I've wasted hours optimizing components that render once per screen navigation. Focus on the hot paths—the code that runs constantly or blocks user interaction.

Send a Message