re the leading cause of stochastic jank.
Core Solution
1. List Virtualization with FlashList
FlatList suffers from recycling inefficiencies and high memory usage for complex items. FlashList uses a different recycling strategy that maintains a constant memory footprint regardless of list size.
Implementation:
Replace FlatList with FlashList. Ensure estimatedItemSize is accurate; this allows FlashList to calculate layout without measuring items, reducing render passes.
// src/components/OptimizedList.tsx
import React from 'react';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { StyleSheet, View, Text } from 'react-native';
interface ListItem {
id: string;
title: string;
data: Record<string, unknown>;
}
interface OptimizedListProps {
items: ListItem[];
renderItem: ListRenderItem<ListItem>;
}
export const OptimizedList: React.FC<OptimizedListProps> = ({ items, renderItem }) => {
return (
<FlashList
data={items}
renderItem={renderItem}
estimatedItemSize={120} // Critical for performance: must be close to actual height
keyExtractor={(item) => item.id}
// Remove unnecessary props that trigger re-renders
// Do not pass onEndReached unless pagination is required
/>
);
};
// Usage
// const renderItem = ({ item }: ListRenderItemInfo<ListItem>) => (
// <ListItemComponent data={item} />
// );
2. Animation Offloading with Reanimated
The legacy Animated API sends every frame update across the bridge. react-native-reanimated compiles animation logic to run directly on the UI thread via Worklets.
Implementation:
Define animations using useAnimatedStyle and runOnUI. Ensure no JS thread dependencies inside the worklet closure unless passed via shared values.
// src/components/HighPerformanceAnimation.tsx
import React from 'react';
import { View, Pressable, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
interface AnimatedButtonProps {
onPress: () => void;
children: React.ReactNode;
}
export const AnimatedButton: React.FC<AnimatedButtonProps> = ({ onPress, children }) => {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: scale.value }],
};
});
const handlePressIn = () => {
scale.value = withSpring(0.95, { damping: 12, stiffness: 300 });
};
const handlePressOut = () => {
scale.value = withSpring(1, { damping: 12, stiffness: 300 }, (finished) => {
if (finished) {
runOnJS(onPress)();
}
});
};
return (
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
<Animated.View style={[styles.button, animatedStyle]}>
{children}
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
// Styles
},
});
3. Referential Equality and Memoization
React Native components re-render if props change referentially. Inline objects and functions break React.memo.
Implementation:
Extract constants. Use useCallback and useMemo strictly. Pass primitive props where possible.
// src/components/MemoizedCard.tsx
import React, { useMemo } from 'react';
import { View, Text, StyleSheet } from 'react-native';
interface CardProps {
id: string;
title: string;
// Avoid passing complex objects; pass primitives
status: 'active' | 'inactive';
onPress: () => void;
}
// Memoize only if the component is expensive or in a list
export const MemoizedCard = React.memo<CardProps>(
({ id, title, status, onPress }) => {
// Derive styles based on props without creating new objects on render
const containerStyle = useMemo(() => {
return status === 'active' ? styles.active : styles.inactive;
}, [status]);
return (
<View style={[styles.card, containerStyle]} onTouchEnd={onPress}>
<Text>{title}</Text>
</View>
);
},
// Custom comparator for strict equality checks if needed
(prev, next) => prev.id === next.id && prev.status === next.status
);
const styles = StyleSheet.create({
card: { padding: 16 },
active: { backgroundColor: '#e0ffe0' },
inactive: { backgroundColor: '#ffe0e0' },
});
4. Image Optimization
Images are a major source of memory pressure. Use react-native-fast-image for caching and resize handling.
Configuration:
- Enable memory and disk caching.
- Use
resizeMode to downscale images on the native side before decoding.
- Prioritize images based on scroll position.
// src/components/OptimizedImage.tsx
import FastImage from 'react-native-fast-image';
import React from 'react';
interface ImageProps {
uri: string;
width: number;
height: number;
}
export const OptimizedImage: React.FC<ImageProps> = ({ uri, width, height }) => {
return (
<FastImage
style={{ width, height }}
source={{
uri,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.cacheOnly, // Use wisely; cacheControl.web for network freshness
}}
// Resize on native side to save memory
resizeMode={FastImage.resizeMode.contain}
/>
);
};
5. Hermes Configuration
Hermes is a bytecode-optimized engine. It reduces startup time and memory usage.
android/app/build.gradle:
project.ext.react = [
enableHermes: true, // Clean and rebuild if changing
]
ios/Podfile:
use_react_native!(
:path => config[:reactNativePath],
# Hermes is enabled by default in RN 0.70+
# Ensure :hermes_enabled is true
)
Pitfall Guide
1. Over-Memoization
Mistake: Wrapping every component in React.memo.
Impact: Increases memory usage and adds comparison overhead. Memoization has a cost; if the component renders in <0.5ms, the memoization check may take longer.
Best Practice: Profile first. Memoize only components in lists, heavy calculation components, or components that re-render frequently due to parent state changes.
2. Inline Objects in Render
Mistake: Passing style={{ flex: 1 }} or onPress={() => {}} directly in JSX within a list.
Impact: Creates a new reference on every render, causing React.memo to fail and triggering cascading re-renders.
Best Practice: Extract styles to StyleSheet.create. Define handlers outside the render function or use useCallback.
Mistake: Using onScroll with Animated.event in the legacy API or updating state on every scroll event.
Impact: Saturates the bridge with scroll position data, causing UI thread starvation.
Best Practice: Use react-native-reanimated's useAnimatedScrollHandler. This runs on the UI thread and updates shared values without crossing the bridge.
4. Heavy Synchronous Computation
Mistake: Performing complex calculations (e.g., image processing, large JSON parsing) on the JS thread during render or event handling.
Impact: Blocks the JS thread, delaying frame updates and input responses.
Best Practice: Offload to native modules or use InteractionManager / requestIdleCallback for non-urgent work. Consider Web Workers for pure JS heavy lifting.
5. Ignoring Garbage Collection (GC)
Mistake: Creating large temporary objects in loops or list items.
Impact: Triggers frequent GC pauses, causing visible stutters.
Best Practice: Reuse objects. Use react-native-mmkv for storage instead of AsyncStorage to avoid JSON serialization/deserialization overhead.
Mistake: Using ScrollView for lists that can grow indefinitely.
Impact: Renders all items at once, leading to O(N) memory usage and massive startup delay.
Best Practice: Always use FlashList or FlatList for dynamic data. ScrollView is strictly for static, small content.
7. Disabling Hermes for Debugging
Mistake: Disabling Hermes in development because source maps are harder to read.
Impact: Development performance does not reflect production. Developers optimize for JSC, missing Hermes-specific issues.
Best Practice: Keep Hermes enabled. Use Flipper or React DevTools with Hermes support for debugging.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| List with >100 items | FlashList | Constant memory footprint; superior recycling algorithm. | Low dev cost; High perf gain. |
| Complex Gesture Animation | Reanimated Worklets | Runs on UI thread; zero bridge latency. | Medium dev cost; Eliminates jank. |
| High-frequency State Update | useRef + InteractionManager | Avoids re-renders; batches updates to idle time. | Low dev cost; Prevents blocking. |
| Storage of Large Objects | react-native-mmkv | Synchronous access; binary storage; no JSON overhead. | Low dev cost; Faster I/O. |
| Heavy Data Processing | Native Module / TurboModule | Offloads to C++/Java/Kotlin; bypasses JS thread. | High dev cost; Max performance. |
Configuration Template
Metro Configuration (metro.config.js):
Optimize bundle size and transformer settings.
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const defaultConfig = getDefaultConfig(__dirname);
const config = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true, // Reduces bundle size and improves startup
},
}),
},
resolver: {
unstable_enablePackageExports: true, // Modern package resolution
},
};
module.exports = mergeConfig(defaultConfig, config);
Babel Configuration for Reanimated (babel.config.js):
Reanimated requires the plugin to be last in the array.
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
// Other plugins...
'react-native-reanimated/plugin', // MUST be last
],
};
Quick Start Guide
-
Install Dependencies:
npm install @shopify/flash-list react-native-reanimated react-native-fast-image react-native-mmkv
cd ios && pod install
-
Configure Reanimated:
Add 'react-native-reanimated/plugin' as the last plugin in babel.config.js. Restart the Metro bundler.
-
Enable Hermes:
Ensure Hermes is enabled in android/app/build.gradle and ios/Podfile. Rebuild the app.
-
Run Release Benchmark:
npx react-native run-android --variant=release
# or
npx react-native run-ios --configuration Release
-
Profile with Flipper:
Open Flipper. Connect to the device. Use the "Layout Inspector" to check view hierarchy depth and the "Performance Monitor" to track FPS and JS thread load. Identify the top 3 bottlenecks and apply the Core Solution patterns.
This article provides the architectural foundation for high-performance React Native applications. Performance optimization is iterative; establish metrics, implement changes, and validate against production data.