Optimising React Native App Performance in 2026

Practical React Native performance optimisations we applied to a production app. From taming re-renders and Zustand store writes to batching API data and making maps usable.

| 12 min read
Optimising React Native App Performance in 2026 blog post header image

React Native performance isn't something you address once and forget about it. App performance is something that can easily and quietly degrade as your app grows. You add more features, screens, more data, more state and before you know if you have alerts on Sentry that performance is degraded in production.

Mostly recently we addressed performance in a React Native app that has a wide user base and has been in production for nearly 3 years so we have been actively building and maintaining for a while now. This is a React Native app that renders hundreds of map markers, manages paginated API data and juggles a fair amount of global state through Zustand. This has been a project that has evolved with a lot with new features added frequently and it became time to focus purely on improving app performance.

The app was working but it wasn't fast. Interactions felt sluggish, the map stuttered on lower-end Android devices and some screens triggered far more re-renders than they had any right to. So we carved out time to dig in and fix it.

Here's what we actually did about it and its practical advice you can carry into your own React Native projects.

The re-render problem

If there's one thing that tanks React Native performance more than anything else it's unnecessary re-renders. The frustrating part is they're often invisible. Your app looks fine, the logic is correct but behind the scenes components are re-rendering dozens of times when they should be rendering once.

We found re-renders hiding in three main places.

Inline component definitions

Re-renders are a classic example of something that creeps in over the life of a project. When the codebase was smaller and screens were simpler, inline component definitions didn't cause any noticeable issues. But as the app grew and re-renders became more frequent these started adding up. It's the kind of thing you learn to spot after working across enough projects and then you go back to older code and see it everywhere.

Defining components inline as anonymous functions passed directly to props creates a brand new component instance on every render.

// Bad: new component instance every render
<ListCell
  LeftComponent={() => (
    <View style={styles.avatarWrapper}>
      <Avatar size={"sm"} />
    </View>
  )}
/>

Every time the parent renders React sees a "new" component because the function reference has changed. It unmounts the old one, mounts the new one and you've just burned cycles for nothing.

The fix is dead simple. Extract it:

// Good: stable reference
const AboutLeft = () => (
  <View style={styles.avatarWrapper}>
    <Avatar size={'sm'} />
  </View>
)

// Then in your render:
<ListCell LeftComponent={AboutLeft} />

Same result, no wasted renders. We found several of these scattered around the codebase. Sometimes when you're focused on shipping features these small issues slip past review.

Zustand selector patterns

This is another one that was fine when it was first written but became a problem over time. To mange local state in our React Native apps we use Zustand.

When app's are small with simple state you can get away with the destructuring pattern in Zustand. However as the store grows with new features like caching large datasets, filters and user preferences this pattern started causing components to re-render on state changes they had nothing to do with. The solution is to adopt selectors. Focusing using selectors over destructing really improved our performance.

If you're using Zustand and destructuring multiple values from the store in one go you're probably causing more re-renders than you think.

// Bad: re-renders when ANY store value changes
const { clearCache, addLocations, getLocationsArray } = useStore()

When you grab the whole store (or a chunk of it) like this your component re-renders whenever any part of the store updates. Not just the bits you care about. The fix is to use individual selectors:

// Good: only re-renders when these specific values change
const clearCache = useStore(state => state.clearCache)
const addLocations = useStore(state => state.addLocations)
const getLocationsArray = useStore(state => state.getLocationsArray)

This single change made a noticeable difference across several screens. Zustand's strict equality checks mean components only re-render when the specific slice of state they're subscribed to actually changes.

Inline callbacks and objects in render

Methods defined directly inside render functions get new references every render which means any child component receiving them as props will also re-render. Same goes for objects and arrays created inline.

// Before: new function reference every render
const placeholderData = () => {
  if (filters?.boundingBox) {
    const cached = getLocationsByBoundingBox(filters.boundingBox)
    if (cached.length > 0) return { data: { locations: cached } }
  }
  return { data: { locations: [] } }
}

// After: stable reference, only recalculates when deps change
const placeholderData = useMemo(() => {
  if (filters?.boundingBox) {
    const cached = getLocationsByBoundingBox(filters.boundingBox)
    if (cached.length > 0) return { data: { locations: cached } }
  }
  return { data: { locations: [] } }
}, [filters?.boundingBox, getLocationsByBoundingBox])

We also wrapped callbacks in useCallback and stored values in useRef where we needed stability without triggering re-renders. Particularly for things like refetch functions and query region tracking.

Map marker performance

As mentioned we have an app displaying hundreds of pins/markers on a map and this was one of the worst offenders for jank. On lower-end Android devices panning around the map felt like wading through treacle.

Only re-render markers when content changes

As is common in mapping apps our map markers held a count in them. We found map markers were redrawing on every render cycle regardless of whether anything had actually changed. We added a ref to track the previous count and only call redraw() when the count genuinely updates:

const prevCountRef = useRef<number | undefined>(count)

useEffect(() => {
  if (markerRef.current && prevCountRef.current !== count) {
    prevCountRef.current = count
    requestAnimationFrame(() => {
      markerRef.current?.redraw()
    })
  }
}, [count])

Wrapping the redraw in requestAnimationFrame also helped smooth things out by letting the browser schedule it between frames rather than forcing it immediately.

Memoising marker content

The marker's visual content (the pin icon and its inner label) was being rebuilt from scratch on every render. Wrapping it in useMemo meant it only recalculates when the pin colour or content actually changes:

const markerContent = useMemo(
  () => (
    <>
      <MapMarker icon={"location-pin"} size={55} color={pinStyle.pinColor} />
      {pinStyle.content && <View style={styles.inner}>{pinStyle.content}</View>}
    </>
  ),
  [pinStyle.pinColor, pinStyle.content]
)

Replacing find() with Map lookups

When a user taps a marker we need to look up the full location data. The original code used Array.find() which is O(n). Not ideal when you're searching through hundreds of locations on every tap:

// Before: O(n) lookup on every interaction
const location = locations.find(l => l.id === locationId)

// After: O(1) lookup using a pre-built Map
const locationsById = useMemo(() => {
  const map = new Map<string, UELocation>()
  locations.forEach(l => map.set(l.id, l))
  return map
}, [locations])

const location = locationId ? locationsById.get(locationId) : undefined

A small change but when it's happening on every marker press event it adds up quickly.

Data fetching and state management

The app fetches paginated location data from an API. Potentially thousands of items across multiple pages. How you handle that data flow matters a lot.

Batching API responses

Originally each page of results was processed and added to the store individually. With dozens of pages that meant dozens of store writes, each one potentially triggering re-renders across the app.

We introduced batching. Process multiple pages before writing to the store in one go:

const BATCH_SIZE = 3

const batches: number[][] = []
for (let i = 0; i < remainingPages; i += BATCH_SIZE) {
  batches.push(
    Array.from(
      { length: Math.min(BATCH_SIZE, remainingPages - i) },
      (_, idx) => i + idx + 2
    )
  )
}

Instead of writing to the store after every single page fetch we accumulate a batch of results and write them together. Fewer store writes means fewer re-renders means a smoother experience.

Deferring expensive formatting

We were formatting location data (calculating distances and enriching fields) before writing it to the store. This meant doing expensive work on every API response even if the user never viewed those locations.

Moving to storing raw data and formatting on read (closer to where it's actually needed) reduced the work done during data fetching significantly:

// Before: format everything upfront
const formattedLocations = formatLocations(
  response.data.locations,
  userLocation
)
addLocations(formattedLocations)

// After: store raw, format when needed
addLocations(response.data.locations)

Replacing JSON.stringify for comparison keys

We were using JSON.stringify to generate cache keys for filter comparisons. It works but it's not fast, especially when called frequently with moderately complex objects. We replaced it with a custom stableKey utility that generates deterministic string keys without the overhead of full serialisation:

// Before
const filtersKey = JSON.stringify(
  Object.entries(filters)
    .filter(([key]) => key !== "boundingBox")
    .sort(([a], [b]) => a.localeCompare(b))
)

// After
const filtersKey = stableKey(filters)

React Query persistence

One of the bigger wins was adding offline cache persistence with React Query's persist plugin. Users opening the app see cached data immediately while fresh data loads in the background:

const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: "REACT_QUERY_OFFLINE_CACHE",
  throttleTime: 1000,
})

We were selective about what gets persisted though. Large datasets like full location lists are excluded to avoid bloating AsyncStorage:

shouldDehydrateQuery: query => {
  const [queryKeyRoot] = query.queryKey
  if (queryKeyRoot === "paginated-locations" || queryKeyRoot === "locations") {
    return false
  }
  return query.state.status === "success" && query.state.data != null
}

Perceived performance

Not every optimisation needs to make things actually faster. Sometimes making things feel faster is just as effective.

Optimistic UI updates

When a user interacts with the app give feedback instantly don't wait on the server round trip to complete. Take this example, say you allow a user to 'favourite' an entity in your app. Would you wait for a 200/201 response from the API before updating the UI? That round trip, even if only a few hundred milliseconds can make the interaction feel sluggish in the real world.

We switched to optimistic updates using React Query's onMutate:

onMutate: async (locationId) => {
  await queryClient.cancelQueries({ queryKey: ['location', locationId] })
  const previous = queryClient.getQueryData(['location', locationId])

  queryClient.setQueryData(['location', locationId], (old) =>
    old ? { data: { location: { ...old.data.location, is_favorite: true } } } : old
  )

  return { previous }
},
onError: (_error, locationId, context) => {
  if (context?.previous) {
    queryClient.setQueryData(['location', locationId], context.previous)
  }
}

The UI updates instantly. If the API call fails, it rolls back. The interaction feels immediate because it is immediate from the user's perspective.

Skeleton loading screens

Instead of showing a blank screen or a generic spinner while data loads we often add skeleton placeholders to our React Native apps. These are UI elements that match the layout of the actual content. This gives users a sense of the page structure before the data arrives which makes load times feel shorter even when they're not.

Reducing initial load time

A few smaller changes that collectively trimmed the initial load:

Extracting heavy work from the critical app flows. Take for example authentication. Once we have the concept of a user it's not uncommon in a React Native app to ask for permissions and load in additional content. Focusing on what we need now and what can wait, we found a quick win changing how we setup FCM token registration and query invalidation post authentication. Moving them to useEffect hooks that fire after the auth state settles meant the login transition felt snappier

Lazy-loading code paths. Extracting non-critical functionality out of the initial render path so the core UI loads faster. Not everything needs to be ready on frame one.

Navigation resets over replacements. Assuming you're using React Navigation use the correct method of navigation. For example use reset instead of replace. We found a case where we used replace which maintains the old stack meaning old screens are kept in memory.

What we'd recommend

If your React Native app is feeling sluggish, here's where we'd start:

  1. Profile first. Don't guess. Use React DevTools Profiler and Why Did You Render to find what's actually re-rendering too much.

  2. Fix your state selectors. Whether you're using Zustand, Redux or Context, make sure components only subscribe to the state they need. This is often one of the highest-impact changes you can make.

  3. Audit your inline functions and objects. Extract components, wrap callbacks in useCallback and memoise computed values. It's tedious but it works.

  4. Think about data flow. Batch writes, defer formatting and cache intelligently. The fewer times you write to state the fewer renders you trigger.

  5. Don't forget perceived performance. Optimistic updates, skeletons and sensible loading states can make your app feel faster without changing a single algorithm.

None of this is groundbreaking computer science. It's just the unglamorous work of finding where your app is doing too much and making it do less. But collectively these changes transformed the app from "a bit sluggish" to "actually snappy" which is exactly what users notice.

It also means the app is more stable on lower end and older devices. This helps keep users happy, reduces support burden and can help with customer satisfaction which in turn can help generate more 5 star reviews on the stores which then leads to more downloads.

Our last recommendation is ask for help. Reach out to experienced React / React Native developer and get their input on your project. On that front if you're working on a React Native app that could use a performance audit, get in touch or book a free 30-minute consultation with the Add Jam team. We've been shipping apps using React Native for 10 years and created multiple maintainable, extensible and performant apps.

Daniel Taylor's avatar

Daniel Taylor

Software Engineer

Recent case studies

Here's a look at some of products we've brought to market recently

With Jack - Freelance Insurance

With Jack - Freelance Insurance

With Jack offers peace of mind and protection for UK freelance creatives and SMEs. Friendly, personable and reliable insurance.

Simple ASO Keyword Tool - Free ASO Platform

Simple ASO Keyword Tool - Free ASO Platform

We built a free, no-nonsense App Store Optimization tool that helps developers avoid common keyword mistakes and boost their app's visibility. What started as an afternoon project has evolved into a suite of free ASO tools helping app creators worldwide get their apps discovered.

PEM Diary - ME/CFS Crash Log

PEM Diary - ME/CFS Crash Log

PEM Diary is a React Native mobile app designed to help individuals with ME/CFS track and document PEM episodes. Built from personal experience, this app serves as a handy tool to understand your condition

We take products from an idea to revenue

Add Jam is your plug in team of web and mobile developers, designers and product managers. We work with you to create, ship and scale digital products that people use and love.

Hello, let's chat 👋
michael hayes avatar photo

Michael Hayes

Co-founder of Add Jam

Hey! Co-founder of Add Jam here. I'm available to chat about startups, tech, design, and development. Drop me a message or book a call in my calendar at a time that suits you.