Performance Tips

Most React Native apps don't need aggressive performance work — good defaults, sensible list handling, and native-driven animations get you most of the way there. But once a screen starts feeling sluggish, it helps to know exactly where re-renders and dropped frames tend to come from, and how to confirm a fix actually helped rather than just guessing. This lesson covers the handful of techniques that matter most in practice: skipping unnecessary re-renders with React.memo(), keeping prop references stable with useCallback and useMemo, using FlatList correctly for long lists, image and animation performance, and profiling before you touch any code.

1. React.memo() — Skip Re-rendering When Props Haven't Changed

As explained in State & Hooks, whenever a component's state changes, React re-renders that component and every child component beneath it by default — even children whose own props never actually changed. For a cheap component that's harmless. But wrap an expensive-to-render component (a big list row, a chart, anything doing real work in its render) in React.memo(), and React will skip re-rendering it whenever its props are shallow-equal to last time.

// ExpensiveProductCard.js — expensive to render, so we memo it
import React from 'react';
import { View, Text, Image } from 'react-native';

function ExpensiveProductCard({ product }) {
  // imagine some genuinely heavy formatting/layout work happening here
  return (
    <View style={{ padding: 12 }}>
      <Image source={{ uri: product.imageUrl }} style={{ width: 80, height: 80 }} />
      <Text>{product.name} - ${product.price}</Text>
    </View>
  );
}

// Wrapping in React.memo() skips a re-render of this component
// whenever "product" is the same reference as last time.
export default React.memo(ExpensiveProductCard);

Now, in a parent screen that re-renders often (say, on every keystroke of a search box), any ExpensiveProductCard whose product prop hasn't changed is skipped entirely instead of being rebuilt from scratch.

Note: React.memo() only helps when it actually prevents wasted work. Wrapping every single component in memo() "just in case" adds its own small comparison overhead and rarely pays off for cheap components — reach for it on components that are genuinely expensive or that re-render very often.

2. useCallback and useMemo — Keeping References Stable

Here's the problem React.memo() alone doesn't solve: every time a parent component re-renders, any function you define inline inside it — like onPress={() => doSomething()} — is a brand-new function, with a new identity, even if its logic is identical to last render. If that new function is passed down as a prop to a memoized child, React sees "this prop is a different reference than last time" and re-renders the child anyway — silently defeating the React.memo() optimization you just added.

useCallback fixes this by returning the same function reference across renders, as long as its dependencies haven't changed. useMemo does the same thing for a computed value instead of a function, avoiding an expensive recalculation on every render.

import React, { useState, useCallback, useMemo } from 'react';
import { View, TextInput } from 'react-native';

export default function ProductScreen({ products }) {
  const [query, setQuery] = useState('');
  const [favoriteId, setFavoriteId] = useState(null);

  // Without useCallback, this is a NEW function every time the
  // user types a character into the search box above — even though
  // its logic never changes. That would defeat memo() on every card.
  const handleFavorite = useCallback((id) => {
    setFavoriteId(id);
  }, []);

  // Without useMemo, filtering would re-run on every render of this
  // screen, not just when "products" or "query" actually change.
  const filteredProducts = useMemo(
    () => products.filter((p) => p.name.includes(query)),
    [products, query]
  );

  return (
    <View>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search..." />
      {filteredProducts.map((p) => (
        <ExpensiveProductCard key={p.id} product={p} onFavorite={handleFavorite} />
      ))}
    </View>
  );
}
  • useCallback(fn, deps) — returns the same function reference until a dependency changes; pass it to memoized children as an event-handler prop.
  • useMemo(fn, deps) — returns the same computed value until a dependency changes; use it for genuinely expensive calculations or derived data, not trivial ones.
  • Neither hook is free — each adds a small amount of bookkeeping. Reach for them when you have a real re-render problem to solve, not by default on every function and value.

3. Use FlatList, Not ScrollView + .map(), for Long Lists

As covered in detail in Lists: FlatList & SectionList, a plain ScrollView combined with .map() renders every single item in the list immediately, whether it's on screen or not. FlatList virtualizes rendering — it only renders the items currently near the visible viewport, mounting new ones as the user scrolls and unmounting ones that scroll far off-screen. For a list of 10 items, this barely matters; for a list of hundreds or thousands, it's the difference between a smooth scroll and a frozen UI.

// Avoid for long/unbounded lists — every row renders up front:
<ScrollView>
  {items.map((item) => <Row key={item.id} item={item} />)}
</ScrollView>

// Prefer — FlatList only renders what's near the viewport:
<FlatList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={renderRow}
/>

4. Never Define renderItem Inline for FlatList

A common, easy-to-miss pitfall: even after switching to FlatList, defining renderItem as a brand-new inline arrow function directly in JSX means a new function is created on every render of the parent — which can force FlatList to treat rows as "changed" and re-render more than necessary, undermining the exact virtualization benefit you switched to FlatList for. Define the render function outside the component (when it needs no closed-over values) or wrap it in useCallback (when it does).

// Avoid — a brand-new function reference on every render:
<FlatList
  data={items}
  renderItem={({ item }) => <Row item={item} />}
/>

// Prefer — stable reference via useCallback:
const renderRow = useCallback(({ item }) => <Row item={item} />, []);

<FlatList data={items} renderItem={renderRow} />
  • Pair a stable renderItem with a memoized row component for the biggest win.
  • Also set keyExtractor properly — an unstable key is its own source of unnecessary re-mounting.

5. Image Size and Caching

As covered in Images & Assets, a remote image loaded at a far larger resolution than it's displayed at wastes memory and download time for no visible benefit — request (or resize) images close to their actual display size. React Native's built-in <Image> component also has fairly basic disk caching behavior across platforms; for screens with many remote images (feeds, galleries, product grids), a dedicated caching library such as react-native-fast-image generally caches more aggressively and reliably, which noticeably smooths out scrolling through image-heavy lists.

  • Size images for their actual display dimensions, not their original upload size.
  • Consider a caching image component for any screen with a lot of remote images.

6. Prefer the Native Driver for Animations

As covered in Animations, setting useNativeDriver: true hands the animation off to run on the native UI thread instead of the JavaScript thread — meaning the animation keeps running smoothly even if the JS thread is momentarily busy with other work. Use it for every animation whose properties support it (opacity, transform), and only fall back to the JS-driven default for the handful of properties that don't (like animating layout properties such as height or width directly).

Animated.timing(fadeAnim, {
  toValue: 1,
  duration: 300,
  useNativeDriver: true, // runs on the native thread, not JS
}).start();

7. Profile Before You Optimize

As covered in Debugging Tools, the single most valuable performance habit is measuring first. Flipper's Performance monitor and the React DevTools Profiler tab both show you exactly which components are re-rendering, how often, and how long each render actually takes — turning "this screen feels slow" into a specific, addressable component and cause. Optimizing without profiling first is a guess: sometimes it targets the wrong component entirely, and sometimes it adds complexity (extra memo()/useCallback wrapping) for a re-render that was never actually expensive to begin with.

Practical rule: If you can't point to a specific component and a specific measured re-render or dropped frame, you're not ready to optimize yet — profile first, then fix the actual bottleneck.

8. Test on a Genuinely Low-End Device

Your development machine (or a recent flagship phone) is almost always far faster than a meaningful slice of your real users' devices. React Native ships to an enormous range of Android hardware, and performance problems that are invisible on a fast device — dropped frames during a list scroll, a slow screen transition, a laggy animation — can be very obvious on an older or budget Android phone. Keep at least one genuinely low-end or a few years old Android device (physical, not just an emulator) in your regular testing routine, not only the newest iPhone or your own development phone.

  • Android device fragmentation is a real, practical concern for React Native apps specifically — test broadly, not just on your best device.
  • A feature that feels instant on your machine can feel sluggish on the hardware a real user actually owns.
Next up: The final lesson in this track, Best Practices, pulls together a checklist from every lesson in this entire track.