React Native Performance: The Complete Guide to Avoiding Re-renders in Expo Apps
[ Mobile Development ]

React Native Performance: The Complete Guide to Avoiding Re-renders in Expo Apps

Master React Native performance optimization with comprehensive strategies for controlling re-renders using useMemo, useCallback, React.memo, FlashList, Hermes profiling, bundle size reduction, and backend API tuning for lightning-fast Expo applications.

→ featured
→ essential
→ timely
By Paul Badarau 15 min read
[ Article Content ]
Share this article:
P
Paul Badarau
Author

React Native Performance: The Complete Guide to Avoiding Re-renders in Expo Apps

Meta Description: Master React Native performance optimization with comprehensive strategies for controlling re-renders using useMemo, useCallback, React.memo, FlashList, Hermes profiling, bundle size reduction, and backend API tuning for lightning-fast Expo applications.

The Hook: When Butter-Smooth UI Turns to Molasses

Your Expo app felt fast during development with 10 test items in the list. Now you've launched, users have 500 items, and the UI is janky. Scrolling stutters. Button taps take 300ms to respond. The keyboard lags when typing. Users write reviews saying "slow and buggy," and your team's morale drops with your App Store rating.

The problem isn't React Native—it's re-renders. Every state change in a parent component triggers re-renders of every child component, even components whose props didn't change. Each re-render runs the component function, creates new objects, re-calculates values, and regenerates virtual DOM. For small component trees, this overhead is invisible. For real-world apps with hundreds of components rendered simultaneously, unchecked re-renders destroy performance.

The frustrating part: performance problems don't appear during development. They emerge gradually as features accumulate, data sets grow, and component trees deepen. By the time users complain, fixing the problem feels like whack-a-mole—you optimize one screen, another gets slow. You don't have a systematic performance strategy, only reactive fixes.

This comprehensive guide provides the complete performance optimization framework for Expo apps. We'll cover how to identify unnecessary re-renders with React DevTools Profiler, control when components re-render using memoization patterns, implement efficient list rendering with FlashList, reduce bundle size to speed initial load, profile JavaScript thread performance on real devices, and optimize backend APIs to minimize data transfer. You'll learn not just the tactics (useMemo, useCallback, React.memo) but the strategy—when to apply each, how to measure impact, and how to build performance awareness into your development process.

By the end, you'll ship Expo apps that maintain 60fps scrolling, respond instantly to user interactions, and scale gracefully from 10 to 10,000 items without degradation.

For modern Expo architecture fundamentals, see /blog/react-native-expo-modern-guide. For backend API optimization, reference /blog/rails-api-best-practices.

Understanding Re-Render Causes

What Triggers Re-Renders

React components re-render when:

1. State changes:

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <Button 
      onPress={() => setCount(count + 1)}
      title={`Count: ${count}`}
    />
  );
  // Every setCount triggers re-render of Counter and all children
}

2. Props change:

function UserProfile({ user }) {
  return <Text>{user.name}</Text>;
  // Re-renders whenever user prop changes
}

3. Parent re-renders (default behavior):

function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <>
      <Button onPress={() => setCount(count + 1)} title="Increment" />
      <ExpensiveChild data="static" />
      {/* ExpensiveChild re-renders even though its props never change! */}
    </>
  );
}

The Performance Cost

Each re-render:

  • Runs the component function
  • Generates new virtual DOM
  • Runs all hooks (useMemo, useEffect, etc.)
  • Diffs virtual DOM against previous
  • Updates native UI if needed

For a single component, this takes microseconds. For a screen with 200 components where 180 don't need to update, you're wasting 90% of render work.

Measuring the Problem

React DevTools Profiler:

# Install React DevTools
npm install --save-dev react-devtools

# Start in separate terminal
npx react-devtools

# Run Expo app with profiling enabled
expo start

In DevTools:

  1. Open Profiler tab
  2. Click record (circle icon)
  3. Interact with your app (scroll list, tap buttons)
  4. Stop recording
  5. Examine flame graph—taller flames = more render time

Look for:

  • Components rendering unnecessarily (props didn't change)
  • Deep component trees rendering together
  • Frequent renders during scrolling or typing

Example: Identifying Unnecessary Renders

// app/(tabs)/projects.tsx
import { useState } from 'react';
import { View, Button, Text } from 'react-native';

function ExpensiveComponent({ data }) {
  console.log('ExpensiveComponent rendered');
  // Simulate expensive calculation
  const processed = data.map(item => ({
    ...item,
    calculated: item.value * 2
  }));
  
  return (
    <View>
      {processed.map(item => (
        <Text key={item.id}>{item.calculated}</Text>
      ))}
    </View>
  );
}

export default function Projects() {
  const [filter, setFilter] = useState('all');
  const [count, setCount] = useState(0);
  
  const data = [{ id: 1, value: 10 }, { id: 2, value: 20 }];
  
  return (
    <View>
      <Button 
        title={`Count: ${count}`}
        onPress={() => setCount(count + 1)}
      />
      
      <ExpensiveComponent data={data} />
      {/* Problem: ExpensiveComponent re-renders every time count changes,
          even though data never changes! */}
    </View>
  );
}

Every button tap logs "ExpensiveComponent rendered" even though data is static.

Optimization Strategy 1: React.memo

Prevent Re-Renders When Props Don't Change

import { memo } from 'react';

// Without memo: Re-renders every time parent updates
function ExpensiveComponent({ data }) {
  console.log('Rendered');
  return <Text>{data.length} items</Text>;
}

// With memo: Only re-renders when data prop changes
const ExpensiveComponent = memo(function ExpensiveComponent({ data }) {
  console.log('Rendered');
  return <Text>{data.length} items</Text>;
});

When to use React.memo:

  • ✅ Pure components (output depends only on props)
  • ✅ Components rendered frequently by parent state changes
  • ✅ Components with expensive rendering logic
  • ❌ Components that already re-render rarely
  • ❌ Components with props that change every render anyway

Custom Comparison Function

By default, memo does shallow prop comparison. For complex props, provide custom comparison:

interface ProjectCardProps {
  project: {
    id: string;
    name: string;
    updatedAt: Date;
    stats: { views: number; likes: number };
  };
}

const ProjectCard = memo(
  function ProjectCard({ project }: ProjectCardProps) {
    return (
      <View>
        <Text>{project.name}</Text>
        <Text>{project.stats.views} views</Text>
      </View>
    );
  },
  (prevProps, nextProps) => {
    // Return true if props are equal (should NOT re-render)
    // Only care about id and updatedAt
    return (
      prevProps.project.id === nextProps.project.id &&
      prevProps.project.updatedAt === nextProps.project.updatedAt
    );
  }
);

This prevents re-renders when unrelated project fields change.

Optimization Strategy 2: useMemo

Memoize Expensive Calculations

import { useMemo } from 'react';

function ProjectList({ projects, filter }) {
  // Without useMemo: Filters entire array on every render
  const filtered = projects.filter(p => p.status === filter);
  
  // With useMemo: Only recalculates when projects or filter change
  const filtered = useMemo(() => {
    console.log('Filtering projects');
    return projects.filter(p => p.status === filter);
  }, [projects, filter]);
  
  return (
    <View>
      {filtered.map(p => <Text key={p.id}>{p.name}</Text>)}
    </View>
  );
}

When to use useMemo:

  • ✅ Expensive computations (filtering large arrays, complex calculations)
  • ✅ Values passed as props to memoized children
  • ✅ Values used as dependencies in other hooks
  • ❌ Simple calculations (arithmetic, string concatenation)
  • ❌ Premature optimization before profiling

Example: Sorting and Filtering Large Lists

import { useMemo, useState } from 'react';

interface Project {
  id: string;
  name: string;
  priority: number;
  status: 'active' | 'archived';
  createdAt: Date;
}

function ProjectDashboard({ projects }: { projects: Project[] }) {
  const [sortBy, setSortBy] = useState<'name' | 'priority'>('name');
  const [filterStatus, setFilterStatus] = useState<'all' | 'active'>('all');
  
  // Memoize filtered and sorted data
  const displayedProjects = useMemo(() => {
    console.log('Recalculating displayed projects');
    
    let result = projects;
    
    // Filter
    if (filterStatus !== 'all') {
      result = result.filter(p => p.status === filterStatus);
    }
    
    // Sort
    result = [...result].sort((a, b) => {
      if (sortBy === 'name') {
        return a.name.localeCompare(b.name);
      }
      return b.priority - a.priority;
    });
    
    return result;
  }, [projects, sortBy, filterStatus]);
  
  return (
    <View>
      <Button title="Sort by Name" onPress={() => setSortBy('name')} />
      <Button title="Sort by Priority" onPress={() => setSortBy('priority')} />
      
      {displayedProjects.map(p => (
        <Text key={p.id}>{p.name}</Text>
      ))}
    </View>
  );
}

Without useMemo, every render would sort and filter the entire projects array, even if projects/sortBy/filterStatus didn't change.

Optimization Strategy 3: useCallback

Stabilize Function References

import { useCallback, memo } from 'react';

// Child component expects stable onPress prop
const Button = memo(function Button({ onPress, title }) {
  console.log('Button rendered:', title);
  return <Pressable onPress={onPress}><Text>{title}</Text></Pressable>;
});

function Parent() {
  const [count, setCount] = useState(0);
  
  // Bad: New function created every render
  const handlePress = () => {
    console.log('Pressed');
  };
  
  // Good: Stable function reference
  const handlePress = useCallback(() => {
    console.log('Pressed');
  }, []);
  
  return (
    <>
      <Button onPress={handlePress} title="Click me" />
      <Text>{count}</Text>
      <Button onPress={() => setCount(count + 1)} title="Increment" />
    </>
  );
}

Without useCallback: Every parent render creates new handlePress function, causing memoized Button to re-render (function reference changed).

With useCallback: handlePress reference stays stable, Button doesn't re-render.

useCallback with Dependencies

function ChatScreen({ userId }) {
  const [messages, setMessages] = useState([]);
  
  const sendMessage = useCallback((text: string) => {
    const message = {
      id: Date.now(),
      userId,  // Uses userId from closure
      text,
      timestamp: new Date()
    };
    setMessages(prev => [...prev, message]);
  }, [userId]);  // Re-create callback when userId changes
  
  return <MessageInput onSend={sendMessage} />;
}

Dependency rule: Include any variables from component scope used in the callback.

Common Pattern: Event Handlers with State

function TodoList() {
  const [todos, setTodos] = useState([]);
  
  // BAD: Includes todos in dependencies, recreates on every todo change
  const handleToggle = useCallback((id: string) => {
    setTodos(todos.map(t => 
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }, [todos]);  // Recreates callback every time todos changes
  
  // GOOD: Use functional state update, no dependency on todos
  const handleToggle = useCallback((id: string) => {
    setTodos(prev => prev.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }, []);  // Stable callback
  
  return (
    <FlatList
      data={todos}
      renderItem={({ item }) => (
        <TodoItem todo={item} onToggle={handleToggle} />
      )}
    />
  );
}

Optimization Strategy 4: FlashList for Large Lists

Replace FlatList with FlashList

FlatList re-renders all visible items when data changes. FlashList recycles views:

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

<FlatList
  data={projects}
  renderItem={({ item }) => <ProjectCard project={item} />}
  keyExtractor={item => item.id}
/>

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

<FlashList
  data={projects}
  renderItem={({ item }) => <ProjectCard project={item} />}
  estimatedItemSize={100}  // Approximate item height
/>

Performance improvement: 5-10x faster rendering for lists with 100+ items.

Installation:

npx expo install @shopify/flash-list

FlashList Best Practices

1. Provide estimatedItemSize:

<FlashList
  data={items}
  renderItem={renderItem}
  estimatedItemSize={72}  // Measured average item height
/>

Closer estimate = better performance. Measure your items in Flexbox layout.

2. Memoize renderItem:

const renderItem = useCallback(({ item }: { item: Project }) => {
  return <ProjectCard project={item} />;
}, []);

<FlashList data={projects} renderItem={renderItem} estimatedItemSize={80} />

3. Use getItemType for varied layouts:

<FlashList
  data={items}
  renderItem={({ item }) => {
    if (item.type === 'header') return <Header />;
    if (item.type === 'ad') return <AdBanner />;
    return <Item data={item} />;
  }}
  getItemType={item => item.type}
  estimatedItemSize={60}
/>

This tells FlashList to use separate view pools for different item types.

4. Avoid inline styles:

// Bad: Creates new style object every render
<View style={{ padding: 16, backgroundColor: '#fff' }}>

// Good: Defined once outside component
const styles = StyleSheet.create({
  container: {
    padding: 16,
    backgroundColor: '#fff'
  }
});

<View style={styles.container}>

Before/After Performance Comparison

// BEFORE: FlatList with 1000 items
// - Initial render: 850ms
// - Scroll: 45 FPS (janky)
// - Memory: 180 MB

// AFTER: FlashList with same 1000 items
// - Initial render: 120ms
// - Scroll: 60 FPS (smooth)
// - Memory: 95 MB

Profiling and Measurement

JavaScript Thread Profiling

# Run on real device (simulators give false results)
expo start

# Open Expo Go on device, press 'd' in terminal
# Select "Start Profiling"
# Interact with app (scroll, navigate)
# Press 'd' again, select "Stop Profiling"
# Download profile, open in Chrome DevTools

What to look for:

  • Functions taking >16ms (causes frame drops)
  • Repeated renders of same component
  • Long-running loops or calculations

Hermes Profiler (Production)

Hermes (default in Expo SDK 50+) has built-in profiling:

# Build with Hermes profiler enabled
eas build --platform android --profile preview

# Download .apk, install on device
# Start profiling:
adb shell am broadcast -a com.facebook.soloader.enabled

# Use app, then stop profiling
adb pull /data/local/tmp/hermes/sampling-profiler-trace*.json

# Open in Chrome DevTools > JavaScript Profiler

React DevTools Profiler Settings

// Enable profiling in development
import { enableScreens } from 'react-native-screens';
enableScreens(true);

// Track why components re-render
if (__DEV__) {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}

This logs to console whenever components render unnecessarily.

Bundle Size Optimization

Analyze Bundle

# Generate bundle analysis
npx expo export --platform android
npx @expo/webpack-config --analyze

# Shows bundle composition:
# - Which libraries are largest
# - Which components are biggest

Lazy Load Screens

// app/_layout.tsx - Expo Router lazy loading
import { lazy, Suspense } from 'react';

const HomeScreen = lazy(() => import('./home'));
const ProfileScreen = lazy(() => import('./profile'));

export default function Layout() {
  return (
    <Suspense fallback={<LoadingScreen />}>
      <Stack>
        <Stack.Screen name="home" component={HomeScreen} />
        <Stack.Screen name="profile" component={ProfileScreen} />
      </Stack>
    </Suspense>
  );
}

Bundle impact: Reduce initial load by 30-50% by deferring non-critical screens.

Remove Unused Dependencies

# Audit dependencies
npx depcheck

# Remove unused packages
npm uninstall unused-package

Use Hermes for Smaller Bundles

// app.json
{
  "expo": {
    "jsEngine": "hermes",
    "android": {
      "enableProguardInReleaseBuilds": true,
      "enableShrinkResourcesInReleaseBuilds": true
    }
  }
}

Hermes reduces Android bundle size by 40%+ and improves startup time.

Backend API Optimization

Paginate API Responses

// Frontend: Paginated fetching
import { useInfiniteQuery } from '@tanstack/react-query';

function useProjects() {
  return useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: ({ pageParam = 1 }) =>
      fetch(`/api/projects?page=${pageParam}&per_page=20`).then(r => r.json()),
    getNextPageParam: (lastPage, pages) =>
      lastPage.hasMore ? pages.length + 1 : undefined
  });
}

function ProjectList() {
  const { data, fetchNextPage, hasNextPage } = useProjects();
  
  return (
    <FlashList
      data={data?.pages.flatMap(page => page.projects) ?? []}
      renderItem={renderItem}
      onEndReached={() => hasNextPage && fetchNextPage()}
      estimatedItemSize={80}
    />
  );
}

Backend: Follow /blog/rails-api-best-practices for pagination implementation.

Minimize Response Payloads

// Bad: Send entire objects (10KB per item)
GET /api/projects
[
  {
    id: 1,
    name: "Project A",
    description: "...",  // 2KB
    fullContent: "...",  // 5KB
    metadata: {...},     // 2KB
    relatedProjects: [...] // 1KB
  }
]

// Good: Send only list-view data (1KB per item)
GET /api/projects
[
  {
    id: 1,
    name: "Project A",
    thumbnail: "url",
    status: "active"
  }
]

// Fetch details only when needed
GET /api/projects/1
{
  id: 1,
  name: "Project A",
  description: "...",
  fullContent: "...",
  // ... full details
}

Impact: 10x smaller payloads for list screens, dramatically faster load.

Implement Caching

import { useQuery } from '@tanstack/react-query';

function useProject(id: string) {
  return useQuery({
    queryKey: ['project', id],
    queryFn: () => fetch(`/api/projects/${id}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000,  // Consider fresh for 5 minutes
    cacheTime: 30 * 60 * 1000  // Keep in cache for 30 minutes
  });
}

Reduces unnecessary API calls by serving cached data.

For Rails API optimization patterns, see /blog/rails-api-best-practices. For React Native + Rails integration, reference /blog/rails-expo-integration.

Performance Checklist

Component Optimization

List Optimization

Bundle Optimization

API Optimization

Profiling

Conclusion: Build Performance In, Don't Bolt It On

React Native performance isn't about memorizing optimization tricks—it's about understanding how React's render cycle works and building awareness into your development workflow. The patterns covered here—memoization for stable props, useMemo for expensive calculations, useCallback for stable functions, FlashList for efficient lists, and lean API payloads—form a systematic approach that scales from small apps to production applications with thousands of screens and millions of users.

Start with profiling. You can't optimize what you don't measure. Use React DevTools Profiler to identify re-render hotspots, then apply targeted optimizations: React.memo for pure components that re-render unnecessarily, useMemo for expensive transformations, FlashList for large scrollable lists. Don't optimize blindly—profile first, optimize the bottlenecks, then measure improvement.

The biggest performance wins come from architecture, not tactics: Paginate data to keep payloads small. Cache aggressively to avoid redundant API calls. Lazy load screens to reduce initial bundle size. Design APIs that return exactly the data the UI needs, no more. These decisions made early compound into dramatically faster applications.

For comprehensive Expo app architecture, see /blog/react-native-expo-modern-guide. For API design that complements frontend performance, reference /blog/rails-api-best-practices.

Take the Next Step

Need to diagnose and fix performance issues in your React Native or Expo app? Elaris can profile your application to identify render bottlenecks, refactor components for optimal memoization, implement FlashList and infinite scroll, optimize bundle size and startup time, and tune backend APIs for minimal data transfer.

We've optimized React Native apps serving millions of users, reduced JavaScript thread CPU usage by 70%, and cut API payload sizes by 90% through targeted architectural improvements. Our team can audit your app's performance profile, implement systematic optimizations, and establish performance budgets that prevent regressions.

Contact us to schedule a performance audit and make your Expo app lightning-fast.

[ Let's Build Together ]

Ready to transform your
business with software?

From strategy to implementation, we craft digital products that drive real business outcomes. Let's discuss your vision.

Related Topics:
React Native Expo performance FlashList 2025