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.
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
renderItemwith a memoized row component for the biggest win. - Also set
keyExtractorproperly — 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.
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.