
Expo
- 500 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
expo is a curated agent skill with 42 performance rules across 8 categories that guides Expo React Native code generation and review for developers who need measurable mobile startup, list, and bundle improvements.
About
expo is a pproenca/dot-skills curated agent skill delivering Expo React Native performance optimization guidance across 42 rules in 8 impact-prioritized categories. Categories span launch time, bundle size, list virtualization, image loading, navigation, re-render prevention, animation performance, and memory management, each prefixed for retrieval such as launch-, bundle-, and list-. The skill triggers when writing, reviewing, or refactoring React Native components, navigation stacks, long feeds, image caches, animations, or release bundles. It recommends concrete patterns including Hermes, FlashList, expo-image, native stack navigation, memoization, and bundle analysis. Listed at 455 installs on skills.sh, expo helps agents apply critical startup and bundle rules before lower-impact micro-optimizations. Install with `npx skills add https://github.com/pproenca/dot-skills --skill expo`.
- Scaffolds complete Expo React Native projects from natural language prompts
- Generates navigation, components, styling and configuration files
- Integrates with Claude Code, Cursor and other agentic editors
- Handles Expo-specific config, app.json, and native module setup
- Supports rapid mobile app iteration for indie builders
Expo by the numbers
- 500 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #624 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill expoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 500 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you optimize Expo React Native app performance?
Generate React Native and Expo configuration, components, and project scaffolding with AI agents.
Who is it for?
React Native developers building or reviewing Expo apps who need structured, impact-ranked performance rules instead of ad-hoc optimization guesses.
Skip if: Developers working on non-React-Native web frontends, native Swift or Kotlin apps without Expo, or greenfield scaffolding with no performance concerns.
When should I use this skill?
Expo React Native code involves slow startup, large bundles, long lists, image loading, navigation jank, or memory leaks.
What you get
Prioritized performance recommendations, refactored component patterns, and rule-mapped changes across launch, bundle, list, and image categories.
- Performance rule recommendations
- Refactored component patterns
By the numbers
- Contains 42 performance rules across 8 categories
- Listed at 455 installs on skills.sh
Files
Community Expo React Native Best Practices
Comprehensive performance optimization guide for Expo React Native applications. Contains 54 rules across 9 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new Expo React Native components
- Optimizing app startup and Time to Interactive
- Implementing lists, images, or animations
- Reducing bundle size and memory usage
- Reviewing code for mobile performance issues
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Launch Time Optimization | CRITICAL | launch- |
| 2 | Bundle Size Optimization | CRITICAL | bundle- |
| 3 | List Virtualization | HIGH | list- |
| 4 | Image Optimization | HIGH | image- |
| 5 | Data Fetching Patterns | HIGH | data- |
| 6 | Navigation Performance | MEDIUM-HIGH | nav- |
| 7 | Re-render Prevention | MEDIUM | rerender- |
| 8 | Animation Performance | MEDIUM | anim- |
| 9 | Memory Management | LOW-MEDIUM | mem- |
Quick Reference
1. Launch Time Optimization (CRITICAL)
launch-splash-screen-control- Control splash screen visibility during asset loadinglaunch-preload-critical-assets- Preload fonts and images during splashlaunch-hermes-engine- Use Hermes engine for faster startuplaunch-defer-non-critical- Defer non-critical initializationlaunch-new-architecture- Enable New Architecture for synchronous native communicationlaunch-minimize-root-imports- Minimize imports in root App component
2. Bundle Size Optimization (CRITICAL)
bundle-avoid-barrel-files- Avoid barrel file importsbundle-analyze-size- Analyze bundle size before releasebundle-remove-unused-dependencies- Remove unused dependenciesbundle-split-by-architecture- Generate architecture-specific APKsbundle-enable-proguard- Enable ProGuard for Android release buildsbundle-optimize-fonts- Subset custom fonts to used charactersbundle-use-lightweight-alternatives- Use lightweight library alternatives
3. List Virtualization (HIGH)
list-use-flashlist- Use FlashList instead of FlatListlist-provide-estimated-size- Provide accurate estimatedItemSizelist-avoid-inline-functions- Avoid inline functions in renderItemlist-provide-getitemlayout- Provide getItemLayout for fixed-height itemslist-avoid-key-prop- Avoid key prop inside FlashList itemslist-batch-rendering- Configure list batch renderinglist-memoize-item-components- Memoize list item components
4. Image Optimization (HIGH)
image-use-expo-image- Use expo-image instead of React Native Imageimage-resize-to-display-size- Resize images to display sizeimage-use-webp-format- Use WebP format for smaller file sizesimage-use-placeholders- Use BlurHash or ThumbHash placeholdersimage-preload-critical- Preload critical above-the-fold imagesimage-lazy-load-offscreen- Lazy load off-screen images
5. Data Fetching Patterns (HIGH)
data-parallel-fetching- Fetch independent data in paralleldata-request-deduplication- Deduplicate concurrent requestsdata-abort-requests- Abort requests on component unmountdata-pagination- Implement efficient pagination strategiesdata-cache-strategies- Use appropriate caching strategiesdata-optimistic-updates- Apply optimistic updates for responsiveness
6. Navigation Performance (MEDIUM-HIGH)
nav-use-native-stack- Use native stack navigatornav-unmount-inactive-screens- Unmount inactive tab screensnav-prefetch-screen-data- Prefetch data before navigationnav-optimize-screen-options- Optimize screen optionsnav-avoid-deep-nesting- Avoid deeply nested navigators
7. Re-render Prevention (MEDIUM)
rerender-use-memo-components- Memoize expensive components with React.memorerender-use-callback- Stabilize callbacks with useCallbackrerender-use-memo-values- Memoize expensive computations with useMemorerender-avoid-context-overuse- Avoid overusing Context for frequent updatesrerender-split-component-state- Split components to isolate updating statererender-use-react-compiler- Enable React Compiler for automatic memoizationrerender-avoid-anonymous-components- Avoid anonymous components in JSX
8. Animation Performance (MEDIUM)
anim-use-reanimated- Use Reanimated for UI thread animationsanim-use-native-driver- Enable useNativeDriver for Animated APIanim-avoid-layout-animation- Prefer transform over layout animationsanim-gesture-handler-integration- Use Gesture Handler with Reanimatedanim-interaction-manager- Defer heavy work during animations
9. Memory Management (LOW-MEDIUM)
mem-cleanup-useeffect- Clean up subscriptions and timersmem-abort-fetch-requests- Abort fetch requests on unmountmem-avoid-closure-leaks- Avoid closure-based memory leaksmem-release-heavy-resources- Release heavy resources when not neededmem-profile-with-tools- Profile memory usage with development tools
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
- Example rules: launch-splash-screen-control, list-use-flashlist
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
Expo React Native
Version 0.1.0 Community January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive performance optimization guide for Expo React Native applications, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact from critical (launch time, bundle size) to incremental (memory management). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Launch Time Optimization — CRITICAL
- 1.1 Control Splash Screen Visibility During Asset Loading — CRITICAL (prevents white flash and improves perceived startup time)
- 1.2 Defer Non-Critical Initialization Until After First Render — CRITICAL (reduces Time to Interactive by 200-500ms)
- 1.3 Enable New Architecture for Synchronous Native Communication — CRITICAL (significant startup improvement, eliminates bridge serialization overhead)
- 1.4 Minimize Imports in Root App Component — HIGH (reduces initial bundle parse time by 100-300ms)
- 1.5 Preload Critical Assets During Splash Screen — CRITICAL (eliminates asset loading delays after app renders)
- 1.6 Use Hermes Engine for Faster Startup — CRITICAL (30-50% faster startup time, reduced memory usage)
2. Bundle Size Optimization — CRITICAL
- 2.1 Analyze Bundle Size Before Release — CRITICAL (identifies 30-70% of bundle bloat from unused dependencies)
- 2.2 Avoid Barrel File Imports — CRITICAL (200-800ms bundle parse reduction, smaller memory footprint)
- 2.3 Enable ProGuard for Android Release Builds — HIGH (10-30% smaller APK, removes unused Java/Kotlin code)
- 2.4 Generate Architecture-Specific APKs — HIGH (30-50% smaller APK download size)
- 2.5 Remove Unused Dependencies — CRITICAL (100-500KB reduction per removed library)
- 2.6 Subset Custom Fonts to Used Characters — MEDIUM-HIGH (50-90% font file size reduction)
- 2.7 Use Lightweight Library Alternatives — HIGH (50-200KB savings per replaced library)
3. List Virtualization — HIGH
- 3.1 Avoid Inline Functions in List renderItem — HIGH (prevents component recreation on every render)
- 3.2 Avoid key Prop Inside FlashList Items — HIGH (preserves cell recycling, prevents performance degradation)
- 3.3 Configure List Batch Rendering for Scroll Performance — MEDIUM-HIGH (reduces blank areas during fast scrolling)
- 3.4 Memoize List Item Components — HIGH (prevents re-render of all items when list data changes)
- 3.5 Provide Accurate estimatedItemSize for FlashList — HIGH (eliminates layout recalculation, smoother initial render)
- 3.6 Provide getItemLayout for Fixed-Height FlatList Items — HIGH (eliminates async layout measurement, instant scroll-to-index)
- 3.7 Use FlashList Instead of FlatList for Large Lists — HIGH (54% FPS improvement, 82% CPU reduction via cell recycling)
4. Image Optimization — HIGH
- 4.1 Lazy Load Off-Screen Images — MEDIUM-HIGH (reduces initial memory footprint and network requests)
- 4.2 Preload Critical Above-the-Fold Images — MEDIUM-HIGH (eliminates loading delay for first visible images)
- 4.3 Resize Images to Display Size — HIGH (50-90% memory reduction for oversized images)
- 4.4 Use BlurHash or ThumbHash Placeholders — MEDIUM-HIGH (eliminates layout shift, improves perceived loading speed)
- 4.5 Use expo-image Instead of React Native Image — HIGH (built-in caching, memory optimization, faster loading)
- 4.6 Use WebP Format for Smaller File Sizes — HIGH (25-35% smaller than JPEG, 26% smaller than PNG)
5. Navigation Performance — MEDIUM-HIGH
- 5.1 Avoid Deeply Nested Navigators — MEDIUM (reduces navigation complexity and memory overhead)
- 5.2 Optimize Screen Options to Reduce Navigation Overhead — MEDIUM (reduces header recalculation and re-renders)
- 5.3 Prefetch Data Before Navigation — MEDIUM-HIGH (eliminates loading state on destination screen)
- 5.4 Unmount Inactive Tab Screens to Save Memory — MEDIUM-HIGH (reduces memory footprint by releasing inactive screen resources)
- 5.5 Use Native Stack Navigator for Performance — MEDIUM-HIGH (2× smoother transitions, 30% lower memory per screen)
6. Re-render Prevention — MEDIUM
- 6.1 Avoid Anonymous Components in JSX — MEDIUM (prevents component unmount/remount on every parent render)
- 6.2 Avoid Overusing Context for Frequently Changing State — MEDIUM (prevents global re-renders from state updates)
- 6.3 Enable React Compiler for Automatic Memoization — MEDIUM (automatic useMemo/useCallback insertion, reduced manual optimization)
- 6.4 Memoize Expensive Components with React.memo — MEDIUM (prevents cascading re-renders from parent updates)
- 6.5 Memoize Expensive Computations with useMemo — MEDIUM (prevents redundant calculations on every render)
- 6.6 Split Components to Isolate Frequently Updating State — MEDIUM (reduces re-render scope from N components to 1-3)
- 6.7 Stabilize Callbacks with useCallback — MEDIUM (prevents child re-renders from callback recreation)
7. Animation Performance — MEDIUM
- 7.1 Defer Heavy Work During Animations with InteractionManager — MEDIUM (maintains 60fps by deferring 100-500ms of JS work)
- 7.2 Enable useNativeDriver for Animated API — MEDIUM (offloads animation to native thread, prevents JS thread blocking)
- 7.3 Prefer Transform Animations Over Layout Animations — MEDIUM (avoids layout recalculation on every frame)
- 7.4 Use Gesture Handler with Reanimated for Gesture-Driven Animations — MEDIUM (2-5× smoother gesture response via native thread execution)
- 7.5 Use Reanimated for UI Thread Animations — MEDIUM (consistent 60fps vs 30-45fps with JS thread animations)
8. Memory Management — LOW-MEDIUM
- 8.1 Abort Fetch Requests on Component Unmount — LOW-MEDIUM (prevents state updates on unmounted components)
- 8.2 Avoid Closure-Based Memory Leaks in Callbacks — LOW-MEDIUM (prevents retained references to unmounted component state)
- 8.3 Clean Up Subscriptions and Timers in useEffect — LOW-MEDIUM (prevents memory leaks from orphaned listeners and timers)
- 8.4 Profile Memory Usage with Development Tools — LOW-MEDIUM (prevents 10-100MB memory leaks from reaching production)
- 8.5 Release Heavy Resources When Not Needed — LOW-MEDIUM (50-200MB memory freed when releasing camera/video/maps)
---
References
1. https://reactnative.dev/docs/performance 2. https://docs.expo.dev 3. https://expo.dev/blog/best-practices-for-reducing-lag-in-expo-apps 4. https://www.callstack.com/ebooks/the-ultimate-guide-to-react-native-optimization 5. https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/ 6. https://shopify.github.io/flash-list/ 7. https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications in 1-3 sentences.
Incorrect (description of what's wrong):
// Bad code example that demonstrates the anti-pattern
const badExample = () => {
// Code that causes performance issues
}Correct (description of what's right):
// Good code example that shows the proper approach
const goodExample = () => {
// Optimized code that follows best practices
}Reference: Link to documentation or resource
{
"name": "expo",
"description": "Expo React Native performance optimization guidelines including data fetching patterns",
"version": "1.1.6",
"organization": "Community",
"technology": "Expo React Native",
"date": "January 2026",
"tags": [
"expo",
"react-native",
"mobile",
"performance",
"data-fetching"
],
"totalRules": 54,
"abstract": "Comprehensive performance optimization guide for Expo React Native applications, designed for AI agents and LLMs. Contains 54 rules across 9 categories, prioritized by impact from critical (launch time, bundle size) to incremental (memory management). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://reactnative.dev/docs/performance",
"https://docs.expo.dev",
"https://expo.dev/blog/best-practices-for-reducing-lag-in-expo-apps",
"https://www.callstack.com/ebooks/the-ultimate-guide-to-react-native-optimization",
"https://docs.swmansion.com/react-native-reanimated/docs/guides/performance/",
"https://shopify.github.io/flash-list/",
"https://reactnative.dev/blog/2024/10/23/the-new-architecture-is-here"
]
}
Expo React Native Best Practices
A comprehensive performance optimization skill for Expo React Native applications, containing 42 rules across 8 categories.
Overview
This skill provides actionable performance guidelines for building fast, responsive Expo React Native applications. Rules are prioritized by impact, from critical startup optimizations to incremental memory improvements.
Structure
expo-react-native/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version, organization, references
├── README.md # This file
├── references/
│ ├── _sections.md # Category definitions
│ ├── launch-*.md # Launch time optimization rules
│ ├── bundle-*.md # Bundle size optimization rules
│ ├── list-*.md # List virtualization rules
│ ├── image-*.md # Image optimization rules
│ ├── nav-*.md # Navigation performance rules
│ ├── rerender-*.md # Re-render prevention rules
│ ├── anim-*.md # Animation performance rules
│ └── mem-*.md # Memory management rules
└── assets/
└── templates/
└── _template.md # Template for new rulesGetting Started
Installation
pnpm installBuild the Compiled Guide
pnpm buildValidate the Skill
pnpm validateCreating a New Rule
1. Identify which category the rule belongs to (see table below) 2. Create a new file in references/ with the appropriate prefix 3. Follow the template structure from assets/templates/_template.md 4. Run validation to ensure compliance
Prefix Reference
| Category | Prefix | Impact |
|---|---|---|
| Launch Time Optimization | launch- | CRITICAL |
| Bundle Size Optimization | bundle- | CRITICAL |
| List Virtualization | list- | HIGH |
| Image Optimization | image- | HIGH |
| Navigation Performance | nav- | MEDIUM-HIGH |
| Re-render Prevention | rerender- | MEDIUM |
| Animation Performance | anim- | MEDIUM |
| Memory Management | mem- | LOW-MEDIUM |
Rule File Structure
Each rule file must follow this structure:
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, technique, tool, concept
---
## Rule Title Here
Brief explanation of WHY this matters (1-3 sentences).
**Incorrect (description of problem):**
\`\`\`typescript
// Bad code example
\`\`\`
**Correct (description of solution):**
\`\`\`typescript
// Good code example
\`\`\`
Reference: [Link](https://example.com)File Naming Convention
Rule files follow the pattern: {prefix}-{description}.md
- prefix: Category identifier (e.g.,
launch,list,image) - description: Kebab-case descriptor (e.g.,
splash-screen-control,use-flashlist)
Examples:
launch-hermes-engine.mdlist-use-flashlist.mdimage-use-webp-format.md
Impact Levels
| Level | Description | Examples |
|---|---|---|
| CRITICAL | Foundational issues with multiplicative effects | Startup time, bundle size |
| HIGH | Significant user-facing impact | List performance, image loading |
| MEDIUM-HIGH | Important for smooth UX | Navigation, data fetching |
| MEDIUM | Noticeable improvements | Re-renders, animations |
| LOW-MEDIUM | Incremental gains | Memory leaks, cleanup |
| LOW | Edge case optimizations | Micro-optimizations |
Scripts
| Command | Description |
|---|---|
pnpm build | Compile rules into AGENTS.md |
pnpm validate | Validate skill structure and content |
pnpm lint | Check markdown formatting |
Contributing
1. Read existing rules to understand the style 2. Follow the template exactly 3. Include realistic code examples 4. Quantify impact where possible 5. Run validation before submitting
Quality Guidelines
- Use imperative verbs: "Use", "Avoid", "Enable"
- Quantify claims: "2-10× improvement" not "faster"
- Show realistic code, not strawman examples
- Keep incorrect/correct examples minimal diff
- Include authoritative references
Acknowledgments
Built with guidance from:
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Launch Time Optimization (launch)
Impact: CRITICAL Description: App startup is the first user impression. Time to Interactive directly affects user retention and app store ratings.
2. Bundle Size Optimization (bundle)
Impact: CRITICAL Description: Smaller bundles mean faster downloads, faster parsing by Hermes, and reduced memory pressure during startup.
3. List Virtualization (list)
Impact: HIGH Description: Lists are ubiquitous in mobile apps. Unoptimized lists cause jank, blank frames, and memory exhaustion.
4. Image Optimization (image)
Impact: HIGH Description: Images are often the largest assets. Unoptimized images cause memory pressure, slow renders, and network bottlenecks.
5. Navigation Performance (nav)
Impact: MEDIUM-HIGH Description: Navigation transitions affect perceived performance. Improper stack management causes memory leaks and stuttering.
6. Re-render Prevention (rerender)
Impact: MEDIUM Description: Unnecessary re-renders waste CPU cycles, block the JS thread, and cause frame drops during interactions.
7. Animation Performance (anim)
Impact: MEDIUM Description: Smooth 60fps animations require running on the native UI thread. JS thread animations cause jank and dropped frames.
8. Memory Management (mem)
Impact: LOW-MEDIUM Description: Memory leaks compound over time, eventually causing app crashes and degraded performance on lower-end devices.
Prefer Transform Animations Over Layout Animations
Animating transform properties (translateX, scale, rotate) is significantly faster than animating layout properties (width, height, margin) because transforms don't trigger layout recalculation.
Incorrect (animates layout properties):
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated'
function ExpandingCard() {
const height = useSharedValue(100)
const animatedStyle = useAnimatedStyle(() => ({
height: height.value, // Triggers layout recalculation
}))
const expand = () => {
height.value = withTiming(300)
}
return (
<Animated.View style={[styles.card, animatedStyle]}>
<CardContent />
</Animated.View>
)
}
// Layout recalculates on every animation frameCorrect (uses transform for visual effect):
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated'
function ExpandingCard() {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scaleY: scale.value }], // No layout recalculation
}))
const expand = () => {
scale.value = withTiming(1.5)
}
return (
<Animated.View style={[styles.card, animatedStyle]}>
<CardContent />
</Animated.View>
)
}
// GPU-accelerated, no layout workWhen layout animation is unavoidable, use LayoutAnimation:
import { LayoutAnimation, UIManager, Platform } from 'react-native'
// Enable on Android
if (Platform.OS === 'android') {
UIManager.setLayoutAnimationEnabledExperimental?.(true)
}
function ExpandingList() {
const [expanded, setExpanded] = useState(false)
const toggleExpand = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setExpanded(!expanded)
}
return (
<View style={{ height: expanded ? 300 : 100 }}>
<ListContent />
</View>
)
}Performance hierarchy (fastest to slowest): 1. opacity - GPU compositing only 2. transform - GPU transform matrix 3. backgroundColor - repaint (no layout) 4. width/height - full layout + repaint
Reference: Reanimated Performance Guide
Use Gesture Handler with Reanimated for Gesture-Driven Animations
Combine React Native Gesture Handler with Reanimated for gesture-driven animations that run entirely on the native thread, avoiding JS thread roundtrips.
Incorrect (JS-based gesture handling):
import { PanResponder, Animated, View } from 'react-native'
function DraggableCard() {
const pan = useRef(new Animated.ValueXY()).current
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: Animated.event(
[null, { dx: pan.x, dy: pan.y }],
{ useNativeDriver: false } // Can't use native driver with PanResponder
),
onPanResponderRelease: () => {
Animated.spring(pan, {
toValue: { x: 0, y: 0 },
useNativeDriver: true,
}).start()
},
})
).current
return (
<Animated.View
{...panResponder.panHandlers}
style={{ transform: pan.getTranslateTransform() }}
>
<CardContent />
</Animated.View>
)
}
// Gesture events cross JS bridge, causing lagCorrect (native gesture handling with Reanimated):
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
function DraggableCard() {
const translateX = useSharedValue(0)
const translateY = useSharedValue(0)
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX
translateY.value = event.translationY
})
.onEnd(() => {
translateX.value = withSpring(0)
translateY.value = withSpring(0)
})
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}))
return (
<GestureDetector gesture={gesture}>
<Animated.View style={animatedStyle}>
<CardContent />
</Animated.View>
</GestureDetector>
)
}
// Entire gesture-animation pipeline on UI threadInstallation:
npx expo install react-native-gesture-handlerWrap app with GestureHandlerRootView:
import { GestureHandlerRootView } from 'react-native-gesture-handler'
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RootNavigator />
</GestureHandlerRootView>
)
}Reference: Gesture Handler Documentation
Defer Heavy Work During Animations with InteractionManager
Use InteractionManager.runAfterInteractions to schedule CPU-intensive work after animations and transitions complete. This ensures smooth 60fps animations.
Incorrect (heavy work during navigation transition):
function ProductDetailScreen({ route }) {
const { productId } = route.params
const [relatedProducts, setRelatedProducts] = useState([])
const [reviews, setReviews] = useState([])
useEffect(() => {
// Starts immediately, competes with navigation animation
fetchRelatedProducts(productId).then(setRelatedProducts)
fetchReviews(productId).then(setReviews)
processAnalytics(productId)
}, [productId])
return <ProductDetailContent />
}
// Navigation transition stuttersCorrect (defers work until after transition):
import { InteractionManager } from 'react-native'
function ProductDetailScreen({ route }) {
const { productId } = route.params
const [relatedProducts, setRelatedProducts] = useState([])
const [reviews, setReviews] = useState([])
useEffect(() => {
// Wait for navigation animation to complete
const task = InteractionManager.runAfterInteractions(() => {
fetchRelatedProducts(productId).then(setRelatedProducts)
fetchReviews(productId).then(setReviews)
processAnalytics(productId)
})
return () => task.cancel()
}, [productId])
return <ProductDetailContent />
}
// Smooth navigation, then data loadsWith loading states:
function ProductDetailScreen({ route }) {
const { productId } = route.params
const [isReady, setIsReady] = useState(false)
const [data, setData] = useState(null)
useEffect(() => {
const task = InteractionManager.runAfterInteractions(async () => {
const result = await fetchProductDetails(productId)
setData(result)
setIsReady(true)
})
return () => task.cancel()
}, [productId])
if (!isReady) {
return <ProductDetailSkeleton /> // Show skeleton during animation
}
return <ProductDetailContent data={data} />
}Create custom interaction handles for long animations:
import { InteractionManager } from 'react-native'
function ComplexAnimation() {
const startAnimation = () => {
const handle = InteractionManager.createInteractionHandle()
// Run your animation
Animated.timing(value, { ... }).start(() => {
// Clear handle when animation completes
InteractionManager.clearInteractionHandle(handle)
})
}
}Reference: InteractionManager Documentation
Enable useNativeDriver for Animated API
When using React Native's built-in Animated API, always enable useNativeDriver: true to run animations on the native UI thread instead of the JavaScript thread.
Incorrect (animation runs on JS thread):
import { Animated } from 'react-native'
function FadeInView({ children }) {
const opacity = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 300,
// Missing useNativeDriver - runs on JS thread
}).start()
}, [])
return <Animated.View style={{ opacity }}>{children}</Animated.View>
}
// Animation competes with JS work, may drop framesCorrect (animation runs on native thread):
import { Animated } from 'react-native'
function FadeInView({ children }) {
const opacity = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true, // Runs on UI thread
}).start()
}, [])
return <Animated.View style={{ opacity }}>{children}</Animated.View>
}
// Smooth animation regardless of JS thread activitySupported properties with useNativeDriver:
opacitytransform(translateX, translateY, scale, rotate, etc.)
Not supported (use Reanimated instead):
width,heightbackgroundColorborderRadiusmargin,padding- Layout properties
Parallel animations:
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
}),
]).start()Note: If you need to animate layout properties (height, width, position), use React Native Reanimated instead, which supports all style properties on the native thread.
Reference: Animated API useNativeDriver
Use Reanimated for UI Thread Animations
React Native Reanimated runs animation logic on the UI thread using worklets, avoiding JS thread bottlenecks and achieving consistent 60fps even during heavy JS work.
Incorrect (Animated API blocks on JS thread):
import { Animated, Pressable } from 'react-native'
import { useRef } from 'react'
function AnimatedCard() {
const scale = useRef(new Animated.Value(1)).current
const handlePressIn = () => {
Animated.spring(scale, {
toValue: 0.95,
useNativeDriver: true,
}).start()
}
const handlePressOut = () => {
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
}).start()
}
return (
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
<Animated.View style={{ transform: [{ scale }] }}>
<CardContent />
</Animated.View>
</Pressable>
)
}
// Limited to transform and opacity with useNativeDriverCorrect (Reanimated with worklets):
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated'
import { Pressable } from 'react-native'
function AnimatedCard() {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}))
const handlePressIn = () => {
scale.value = withSpring(0.95)
}
const handlePressOut = () => {
scale.value = withSpring(1)
}
return (
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
<Animated.View style={animatedStyle}>
<CardContent />
</Animated.View>
</Pressable>
)
}Installation:
npx expo install react-native-reanimated// babel.config.js
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'], // Must be last
}
}Reanimated advantages:
- Animate any style property (height, color, borderRadius)
- Synchronous gesture-driven animations
- Shared values between components
- Layout animations
Reference: React Native Reanimated Documentation
Analyze Bundle Size Before Release
Use bundle visualization tools to identify large dependencies and dead code. Many apps ship with unused libraries that significantly increase download and parse time.
Incorrect (blind dependency additions):
{
"dependencies": {
"moment": "^2.29.4",
"lodash": "^4.17.21",
"axios": "^1.6.0",
"react-native-svg": "^14.0.0"
}
}Correct (analyze and optimize):
# Install bundle analyzer
npx react-native-bundle-visualizer
# Or use source-map-explorer
npx expo export --dump-sourcemap
npx source-map-explorer dist/bundles/ios-*.jsReview and replace heavy dependencies:
{
"dependencies": {
"date-fns": "^2.30.0",
"ky": "^1.2.0"
}
}Common heavy dependencies to evaluate:
| Library | Size | Alternative |
|---|---|---|
| moment | 232KB | date-fns (13KB per function) |
| lodash | 72KB | lodash-es + direct imports |
| axios | 48KB | ky (3KB) or fetch |
Bundle size targets:
- Development: Track but don't optimize
- Production: < 2MB JavaScript bundle for fast startup
- Check bundle size in CI to prevent regressions
Reference: React Native Bundle Visualizer
Avoid Barrel File Imports
Import directly from source files instead of barrel files (index.js re-exports). Barrel files force the bundler to load entire libraries even when you only need one component.
Incorrect (imports entire library):
import { Camera, MapView, Notifications } from '@/components'
// Loads ALL exports from components/index.ts
import { format, parse, isValid } from 'date-fns'
// Loads entire date-fns libraryCorrect (direct imports):
import Camera from '@/components/Camera'
import MapView from '@/components/MapView'
import Notifications from '@/components/Notifications'
import format from 'date-fns/format'
import parse from 'date-fns/parse'
import isValid from 'date-fns/isValid'Alternative (configure optimizePackageImports in Metro):
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config')
const config = getDefaultConfig(__dirname)
config.transformer.unstable_optimizePackageImports = [
'date-fns',
'lodash',
'@expo/vector-icons',
]
module.exports = configCommon barrel file offenders:
@expo/vector-icons- import specific icon set insteadlodash- uselodash-esor direct imports- Component libraries with index.ts re-exports
Reference: Metro Configuration
Enable ProGuard for Android Release Builds
ProGuard shrinks and obfuscates Java/Kotlin code, removing unused classes and methods from the native bundle. This reduces APK size and improves security.
Incorrect (ProGuard disabled):
// android/app/build.gradle
android {
buildTypes {
release {
minifyEnabled false
shrinkResources false
}
}
}Correct (ProGuard enabled):
// android/app/build.gradle
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}Required ProGuard rules for React Native:
# proguard-rules.pro
# React Native
-keep class com.facebook.react.** { *; }
-keep class com.facebook.hermes.** { *; }
# Hermes
-keep class com.facebook.jni.** { *; }
# Expo modules (add as needed)
-keep class expo.modules.** { *; }
# Keep native methods
-keepclassmembers class * {
@com.facebook.react.uimanager.annotations.ReactProp *;
}For Expo managed workflow:
ProGuard is enabled by default in production builds. Custom rules can be added via config plugins.
Benefits:
- Removes unused classes and methods
- Obfuscates code for basic protection
- Optimizes bytecode
- Shrinks resources
Reference: Android ProGuard Documentation
Subset Custom Fonts to Used Characters
Custom fonts often include thousands of glyphs for multiple languages. Subset fonts to include only the characters your app actually uses.
Incorrect (full font file):
// Using complete font file (400KB+)
import * as Font from 'expo-font'
await Font.loadAsync({
'Inter-Bold': require('./assets/fonts/Inter-Bold.ttf'), // 300KB
'Inter-Regular': require('./assets/fonts/Inter-Regular.ttf'), // 290KB
})Correct (subsetted fonts):
# Subset font using fonttools (Python)
pip install fonttools brotli
# Subset to Latin characters only
pyftsubset Inter-Bold.ttf \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F" \
--output-file="Inter-Bold-subset.ttf"// Using subsetted font file (40KB)
import * as Font from 'expo-font'
await Font.loadAsync({
'Inter-Bold': require('./assets/fonts/Inter-Bold-subset.ttf'), // 40KB
'Inter-Regular': require('./assets/fonts/Inter-Regular-subset.ttf'), // 38KB
})Alternative (use Google Fonts with expo-google-fonts):
import { useFonts, Inter_700Bold } from '@expo-google-fonts/inter'
// Google Fonts are already optimized and cached
const [fontsLoaded] = useFonts({
Inter_700Bold,
})Common Unicode ranges:
| Range | Description |
|---|---|
| U+0000-00FF | Latin Basic + Supplement |
| U+0100-017F | Latin Extended-A |
| U+0400-04FF | Cyrillic |
| U+0600-06FF | Arabic |
Reference: fonttools Documentation
Remove Unused Dependencies
Audit your package.json regularly for unused dependencies. Libraries installed for experimentation often remain, bloating the bundle even if never imported.
Incorrect (unused dependencies remain):
{
"dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"expo": "^52.0.0",
"react": "18.2.0",
"react-native": "0.76.0",
"react-native-chart-kit": "^6.12.0",
"react-native-maps": "^1.8.0",
"react-native-svg": "^14.0.0",
"victory-native": "^36.6.0"
}
}Correct (audited and cleaned):
# Find unused dependencies
npx depcheck
# Output example:
# Unused dependencies
# * react-native-chart-kit
# * victory-native
# * react-native-maps
# Remove unused
npm uninstall react-native-chart-kit victory-native react-native-mapsResulting clean package.json:
{
"dependencies": {
"@react-native-async-storage/async-storage": "^1.21.0",
"expo": "^52.0.0",
"react": "18.2.0",
"react-native": "0.76.0",
"react-native-svg": "^14.0.0"
}
}Automate in CI:
# .github/workflows/check-deps.yml
- name: Check for unused dependencies
run: npx depcheck --ignores="@types/*,typescript"Note: Some dependencies may appear unused but are required by Metro plugins or native modules. Use --ignores flag for legitimate cases.
Generate Architecture-Specific APKs
Generate separate APKs for each CPU architecture instead of a universal APK. Users only download the binary for their device's architecture.
Incorrect (universal APK):
// android/app/build.gradle
android {
defaultConfig {
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
}
}
}Correct (architecture splits):
// android/app/build.gradle
android {
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
universalApk false // Don't generate universal APK
}
}
}For Expo managed workflow (eas.json):
{
"build": {
"production": {
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
}
}App Bundle (recommended for Play Store):
{
"build": {
"production": {
"android": {
"buildType": "app-bundle"
}
}
}
}Size comparison:
| Type | Size |
|---|---|
| Universal APK | 80MB |
| arm64-v8a APK | 45MB |
| AAB (App Bundle) | Play Store delivers optimized |
Note: Google Play Store requires AAB format since August 2021, which automatically serves architecture-specific installs.
Reference: Expo Build Configuration
Use Lightweight Library Alternatives
Replace heavy libraries with lightweight alternatives that provide the same functionality. Many popular libraries have smaller, more focused replacements.
Incorrect (heavy libraries for simple tasks):
{
"dependencies": {
"moment": "^2.29.4",
"lodash": "^4.17.21",
"axios": "^1.6.0",
"uuid": "^9.0.0"
}
}import moment from 'moment' // 232KB
import _ from 'lodash' // 72KB
import axios from 'axios' // 48KB
import { v4 as uuidv4 } from 'uuid' // 12KBCorrect (lightweight alternatives):
{
"dependencies": {
"date-fns": "^2.30.0",
"ky": "^1.2.0"
}
}import { format, parseISO } from 'date-fns' // ~3KB per function
import ky from 'ky' // 3KB
// Use built-in crypto for UUIDs
import * as Crypto from 'expo-crypto'
const uuid = Crypto.randomUUID()
// Use native array methods instead of lodash
const filtered = items.filter(x => x.active)
const mapped = items.map(x => x.name)
const unique = [...new Set(items)]Library replacements:
| Heavy Library | Size | Alternative | Size |
|---|---|---|---|
| moment | 232KB | date-fns | ~3KB/fn |
| lodash | 72KB | Native methods | 0KB |
| axios | 48KB | ky or fetch | 3KB/0KB |
| uuid | 12KB | expo-crypto | built-in |
| classnames | 2KB | Template literals | 0KB |
| numeral | 33KB | Intl.NumberFormat | 0KB |
Using native Intl APIs:
// Instead of numeral.js
const formatCurrency = (amount) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
// Instead of moment for relative time
const formatRelative = (date) =>
new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(
Math.round((date - Date.now()) / (1000 * 60 * 60 * 24)),
'day'
)When heavy libraries are justified:
- Complex date manipulation (moment-timezone for timezone math)
- Deep cloning with circular refs (lodash.cloneDeep)
- HTTP interceptors and retry logic (axios)
Abort Requests on Component Unmount
Requests continuing after component unmount waste bandwidth and can cause state update errors.
Incorrect (request continues after unmount):
function SearchResults({ query }) {
const [results, setResults] = useState([])
useEffect(() => {
fetchResults(query).then(data => {
// Error if component unmounted before response
// "Can't perform state update on unmounted component"
setResults(data)
})
}, [query])
return <ResultsList results={results} />
}
// User types fast, each keystroke triggers fetch
// Previous fetches continue even after new one startsCorrect (abort on cleanup):
function SearchResults({ query }) {
const [results, setResults] = useState([])
useEffect(() => {
const controller = new AbortController()
async function search() {
try {
const data = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
}).then(r => r.json())
setResults(data)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Search failed:', error)
}
// AbortError is expected on cleanup, don't log it
}
}
search()
return () => {
controller.abort() // Cancel on unmount or query change
}
}, [query])
return <ResultsList results={results} />
}With TanStack Query (automatic cancellation):
function SearchResults({ query }) {
const { data: results } = useQuery({
queryKey: ['search', query],
queryFn: ({ signal }) => {
// TanStack Query provides signal automatically
return fetch(`/api/search?q=${query}`, { signal })
.then(r => r.json())
},
// Abort previous request when query changes
keepPreviousData: true,
})
return <ResultsList results={results ?? []} />
}
// Automatic abort on unmount and query changeDebounced search with abort:
function SearchInput() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
useEffect(() => {
if (!query) {
setResults([])
return
}
const controller = new AbortController()
// Debounce: wait 300ms after typing stops
const timeoutId = setTimeout(async () => {
try {
const data = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
}).then(r => r.json())
setResults(data)
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error)
}
}
}, 300)
return () => {
clearTimeout(timeoutId) // Clear debounce timer
controller.abort() // Abort in-flight request
}
}, [query])
return (
<>
<TextInput value={query} onChangeText={setQuery} />
<ResultsList results={results} />
</>
)
}Abort multiple parallel requests:
useEffect(() => {
const controller = new AbortController()
Promise.all([
fetch('/api/user', { signal: controller.signal }),
fetch('/api/posts', { signal: controller.signal }),
]).then(/* ... */)
return () => controller.abort() // Aborts all requests
}, [])Reference: AbortController MDN
Configure Appropriate Cache Strategies
Different data types need different caching strategies. Static data should cache longer than real-time data.
Incorrect (same cache settings for all data):
// Global default: everything refetches immediately
const queryClient = new QueryClient()
function App() {
// User profile refetches on every mount
const { data: user } = useQuery(['user'], fetchUser)
// Static config refetches on every mount
const { data: config } = useQuery(['config'], fetchConfig)
// Real-time prices refetch on every mount (correct here)
const { data: prices } = useQuery(['prices'], fetchPrices)
// ...
}Correct (tailored cache strategies):
// Static data: cache for hours
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: 1000 * 60 * 60, // Fresh for 1 hour
cacheTime: 1000 * 60 * 60 * 24, // Cache for 24 hours
})
// User data: cache for minutes
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 1000 * 60 * 5, // Fresh for 5 minutes
cacheTime: 1000 * 60 * 30, // Cache for 30 minutes
})
// Real-time data: always refetch
const { data: prices } = useQuery({
queryKey: ['prices'],
queryFn: fetchPrices,
staleTime: 0, // Always stale
refetchInterval: 5000, // Poll every 5 seconds
})Configure defaults by query key prefix:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // Default: 1 minute
cacheTime: 1000 * 60 * 5, // Default: 5 minutes
refetchOnWindowFocus: true,
retry: 2,
},
},
})
// Override for specific queries
queryClient.setQueryDefaults(['static'], {
staleTime: Infinity, // Never stale
cacheTime: Infinity, // Never garbage collected
})
queryClient.setQueryDefaults(['realtime'], {
staleTime: 0,
cacheTime: 1000 * 60,
refetchOnMount: true,
refetchOnWindowFocus: true,
})Cache strategy guidelines:
| Data Type | staleTime | cacheTime | refetchInterval |
|---|---|---|---|
| Static config | Infinity | Infinity | - |
| User profile | 5 min | 30 min | - |
| Feed/timeline | 30 sec | 5 min | - |
| Search results | 0 | 1 min | - |
| Real-time prices | 0 | 1 min | 5 sec |
| Notifications | 0 | 0 | 30 sec |
Persist cache across sessions:
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
})
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<AppContent />
</PersistQueryClientProvider>
)
}
// Cache survives app restartReference: TanStack Query Caching
Use Optimistic Updates for Instant Feedback
Update UI immediately on user action, then sync with server. Revert if the server request fails.
Incorrect (wait for server response):
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
const [isLiking, setIsLiking] = useState(false)
const handleLike = async () => {
setIsLiking(true)
try {
const response = await likePost(postId) // 200-500ms wait
setLikes(response.likes)
} finally {
setIsLiking(false)
}
}
return (
<TouchableOpacity onPress={handleLike} disabled={isLiking}>
<Text>{likes} likes</Text>
{isLiking && <Spinner />}
</TouchableOpacity>
)
}
// User sees delay, button feels unresponsiveCorrect (optimistic update):
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
const handleLike = async () => {
// Optimistic: update immediately
const previousLikes = likes
setLikes(likes + 1)
try {
await likePost(postId)
} catch (error) {
// Revert on failure
setLikes(previousLikes)
Alert.alert('Failed to like post')
}
}
return (
<TouchableOpacity onPress={handleLike}>
<Text>{likes} likes</Text>
</TouchableOpacity>
)
}
// Instant feedback, revert only on errorWith TanStack Query mutations:
function LikeButton({ postId }) {
const queryClient = useQueryClient()
const { mutate: like } = useMutation({
mutationFn: () => likePost(postId),
onMutate: async () => {
// Cancel outgoing refetches
await queryClient.cancelQueries(['post', postId])
// Snapshot previous value
const previousPost = queryClient.getQueryData(['post', postId])
// Optimistically update
queryClient.setQueryData(['post', postId], old => ({
...old,
likes: old.likes + 1,
isLiked: true,
}))
return { previousPost }
},
onError: (err, variables, context) => {
// Revert on error
queryClient.setQueryData(['post', postId], context.previousPost)
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries(['post', postId])
},
})
return (
<TouchableOpacity onPress={() => like()}>
<Text>Like</Text>
</TouchableOpacity>
)
}Optimistic list updates:
function TodoList() {
const queryClient = useQueryClient()
const { mutate: addTodo } = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries(['todos'])
const previousTodos = queryClient.getQueryData(['todos'])
// Add optimistic todo with temporary ID
queryClient.setQueryData(['todos'], old => [
...old,
{ ...newTodo, id: 'temp-' + Date.now(), isPending: true }
])
return { previousTodos }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSuccess: (result, variables, context) => {
// Replace temp item with real data
queryClient.setQueryData(['todos'], old =>
old.map(todo =>
todo.id.startsWith('temp-') ? result : todo
)
)
},
})
// ...
}When NOT to use optimistic updates:
- Financial transactions
- Irreversible actions
- Actions requiring server validation
- When error recovery is complex
Reference: TanStack Query Optimistic Updates
Implement Efficient Pagination for Large Datasets
Loading all data at once causes memory issues and slow initial load. Use pagination to load data incrementally.
Incorrect (load all data at once):
function ProductList() {
const [products, setProducts] = useState([])
useEffect(() => {
// Fetches all 10,000 products at once
fetchAllProducts().then(setProducts)
}, [])
return (
<FlatList
data={products}
renderItem={renderProduct}
/>
)
}
// Huge initial payload, memory pressure, slow renderCorrect (paginated loading):
function ProductList() {
const [products, setProducts] = useState([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return
setIsLoading(true)
try {
const newProducts = await fetchProducts({ page, limit: 20 })
setProducts(prev => [...prev, ...newProducts])
setHasMore(newProducts.length === 20)
setPage(prev => prev + 1)
} finally {
setIsLoading(false)
}
}, [page, isLoading, hasMore])
useEffect(() => {
loadMore()
}, [])
return (
<FlatList
data={products}
renderItem={renderProduct}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={isLoading ? <Spinner /> : null}
/>
)
}With TanStack Query infinite queries:
import { useInfiniteQuery } from '@tanstack/react-query'
function ProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = 1 }) =>
fetchProducts({ page: pageParam, limit: 20 }),
getNextPageParam: (lastPage, pages) =>
lastPage.length === 20 ? pages.length + 1 : undefined,
})
const products = data?.pages.flat() ?? []
return (
<FlatList
data={products}
renderItem={renderProduct}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={isFetchingNextPage ? <Spinner /> : null}
/>
)
}Cursor-based pagination (more efficient):
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) =>
fetchPosts({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
// Cursor-based avoids issues with items added/removed
})Prevent duplicate onEndReached calls:
const isLoadingRef = useRef(false)
const handleEndReached = useCallback(() => {
if (isLoadingRef.current || !hasNextPage) return
isLoadingRef.current = true
fetchNextPage().finally(() => {
isLoadingRef.current = false
})
}, [hasNextPage, fetchNextPage])
<FlatList
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
/>Benefits:
- Fast initial load
- Lower memory usage
- Better scroll performance
- Works with any dataset size
Reference: TanStack Query Infinite Queries
Fetch Independent Data in Parallel
Sequential fetches multiply latency. When requests don't depend on each other, execute them concurrently.
Incorrect (sequential fetches):
function ProfileScreen() {
const [user, setUser] = useState(null)
const [posts, setPosts] = useState(null)
const [followers, setFollowers] = useState(null)
useEffect(() => {
async function loadData() {
const userData = await fetchUser() // 200ms
const postsData = await fetchPosts() // 300ms
const followersData = await fetchFollowers() // 150ms
// Total: 650ms (sequential)
setUser(userData)
setPosts(postsData)
setFollowers(followersData)
}
loadData()
}, [])
// ...
}Correct (parallel fetches):
function ProfileScreen() {
const [data, setData] = useState(null)
useEffect(() => {
async function loadData() {
const [user, posts, followers] = await Promise.all([
fetchUser(), // 200ms
fetchPosts(), // 300ms (runs simultaneously)
fetchFollowers() // 150ms (runs simultaneously)
])
// Total: 300ms (max of all three)
setData({ user, posts, followers })
}
loadData()
}, [])
// ...
}With TanStack Query (automatic parallelization):
function ProfileScreen({ userId }) {
// These run in parallel automatically
const userQuery = useQuery(['user', userId], () => fetchUser(userId))
const postsQuery = useQuery(['posts', userId], () => fetchPosts(userId))
const followersQuery = useQuery(['followers', userId], () => fetchFollowers(userId))
const isLoading = userQuery.isLoading || postsQuery.isLoading || followersQuery.isLoading
if (isLoading) return <Spinner />
return (
<View>
<UserHeader user={userQuery.data} />
<PostList posts={postsQuery.data} />
<FollowerCount count={followersQuery.data.length} />
</View>
)
}useQueries for dynamic parallel queries:
function MultiProductScreen({ productIds }) {
const results = useQueries({
queries: productIds.map(id => ({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
}))
})
const isLoading = results.some(r => r.isLoading)
const products = results.map(r => r.data).filter(Boolean)
// ...
}When sequential is necessary:
// When request B depends on response A
async function loadDependentData() {
const user = await fetchUser() // Need user.id first
const [posts, settings] = await Promise.all([
fetchPosts(user.id), // Parallel after user
fetchSettings(user.id) // Parallel after user
])
}Error handling with Promise.allSettled:
const results = await Promise.allSettled([
fetchUser(),
fetchPosts(),
fetchFollowers()
])
const user = results[0].status === 'fulfilled' ? results[0].value : null
const posts = results[1].status === 'fulfilled' ? results[1].value : []
// App continues even if some requests failReference: TanStack Query Parallel Queries
Deduplicate Concurrent Requests
Multiple components requesting the same data should share a single network request, not make duplicate calls.
Incorrect (duplicate requests):
// components/Header.tsx
function Header() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(setUser) // Request 1
}, [])
return <Text>{user?.name}</Text>
}
// components/Sidebar.tsx
function Sidebar() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(setUser) // Request 2 (duplicate!)
}, [])
return <Avatar uri={user?.avatar} />
}
// components/ProfileBadge.tsx
function ProfileBadge() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(setUser) // Request 3 (duplicate!)
}, [])
return <Badge role={user?.role} />
}
// Three identical network requestsCorrect (deduplicated with TanStack Query):
// All components share the same cached data
// components/Header.tsx
function Header() {
const { data: user } = useQuery(['user'], fetchUser)
return <Text>{user?.name}</Text>
}
// components/Sidebar.tsx
function Sidebar() {
const { data: user } = useQuery(['user'], fetchUser)
return <Avatar uri={user?.avatar} />
}
// components/ProfileBadge.tsx
function ProfileBadge() {
const { data: user } = useQuery(['user'], fetchUser)
return <Badge role={user?.role} />
}
// Single network request, data shared across componentsConfigure stale time to prevent refetches:
// _layout.tsx or App.tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // Data fresh for 1 minute
cacheTime: 1000 * 60 * 5, // Cache for 5 minutes
}
}
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent />
</QueryClientProvider>
)
}Alternative with SWR:
import useSWR from 'swr'
function Header() {
const { data: user } = useSWR('/api/user', fetcher)
return <Text>{user?.name}</Text>
}
function Sidebar() {
// Same key = same cached data, single request
const { data: user } = useSWR('/api/user', fetcher)
return <Avatar uri={user?.avatar} />
}Manual deduplication (if not using a library):
// lib/dedupe.ts
const pending = new Map()
export async function fetchWithDedupe(key, fetcher) {
if (pending.has(key)) {
return pending.get(key)
}
const promise = fetcher().finally(() => {
pending.delete(key)
})
pending.set(key, promise)
return promise
}
// Usage
const user = await fetchWithDedupe('user', fetchUser)Benefits:
- Reduces network traffic
- Faster response (cached data)
- Lower battery consumption
- Consistent data across components
Reference: TanStack Query Deduplication
Lazy Load Off-Screen Images
Only load images when they're about to become visible. For lists, FlashList handles this automatically. For grids and custom layouts, implement manual lazy loading.
Incorrect (all images load immediately):
import { Image } from 'expo-image'
import { ScrollView } from 'react-native'
function PhotoGallery({ photos }) {
return (
<ScrollView>
{photos.map(photo => (
<Image
key={photo.id}
source={{ uri: photo.url }}
style={styles.photo}
/> // All 100 images start loading immediately
))}
</ScrollView>
)
}
// Network saturated, high memory usageCorrect (images load when visible):
import { Image } from 'expo-image'
import { FlashList } from '@shopify/flash-list'
function PhotoGallery({ photos }) {
return (
<FlashList
data={photos}
renderItem={({ item }) => (
<Image
source={{ uri: item.url }}
style={styles.photo}
contentFit="cover"
/>
)}
estimatedItemSize={200}
numColumns={3}
/>
)
}
// FlashList only renders visible items + bufferManual lazy loading with visibility detection:
import { Image } from 'expo-image'
import { View, useWindowDimensions } from 'react-native'
import { useRef, useState, useCallback } from 'react'
function LazyImage({ source, style }) {
const [isVisible, setIsVisible] = useState(false)
const { height: screenHeight } = useWindowDimensions()
const handleLayout = useCallback((event) => {
const { y } = event.nativeEvent.layout
// Load if within 2 screens of viewport
if (y < screenHeight * 2) {
setIsVisible(true)
}
}, [screenHeight])
return (
<View style={style} onLayout={handleLayout}>
{isVisible ? (
<Image source={source} style={style} />
) : (
<View style={[style, styles.placeholder]} />
)}
</View>
)
}expo-image recyclingKey for list image recycling:
<FlashList
data={products}
renderItem={({ item }) => (
<Image
source={{ uri: item.imageUrl }}
recyclingKey={item.id} // Helps with image recycling in lists
style={styles.productImage}
/>
)}
estimatedItemSize={120}
/>Reference: expo-image recyclingKey
Preload Critical Above-the-Fold Images
Preload images that appear immediately when a screen loads. This ensures they're ready to display without network delay.
Incorrect (images load on render):
import { Image } from 'expo-image'
function HomeScreen() {
return (
<View>
<Image
source={{ uri: 'https://cdn.example.com/hero-banner.webp' }}
style={styles.heroBanner}
// Network request starts when component mounts
/>
</View>
)
}Correct (preloaded during splash screen):
import { Image } from 'expo-image'
import * as SplashScreen from 'expo-splash-screen'
SplashScreen.preventAutoHideAsync()
const criticalImages = [
'https://cdn.example.com/hero-banner.webp',
'https://cdn.example.com/logo.webp',
]
async function preloadImages() {
await Promise.all(
criticalImages.map(uri => Image.prefetch(uri))
)
}
export default function App() {
const [ready, setReady] = useState(false)
useEffect(() => {
preloadImages().then(() => setReady(true))
}, [])
useEffect(() => {
if (ready) {
SplashScreen.hideAsync()
}
}, [ready])
if (!ready) return null
return <RootNavigator />
}Prefetch before navigation:
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
function ProductListItem({ product }) {
const router = useRouter()
const handlePress = async () => {
// Start prefetching before navigation completes
Image.prefetch(product.fullImageUrl)
router.push(`/product/${product.id}`)
}
return (
<Pressable onPress={handlePress}>
<Image source={{ uri: product.thumbnailUrl }} />
<Text>{product.name}</Text>
</Pressable>
)
}useImage hook for preloading with metadata:
import { useImage } from 'expo-image'
function ProductDetail({ productId }) {
const imageSource = useImage(`https://cdn.example.com/products/${productId}.webp`)
if (!imageSource) {
return <LoadingSkeleton />
}
return (
<Image
source={imageSource}
style={{ width: imageSource.width, height: imageSource.height }}
/>
)
}Reference: expo-image Prefetching
Resize Images to Display Size
Loading a 4000x3000 image into a 100x100 avatar wastes memory and slows rendering. Resize images server-side or use expo-image's automatic resizing.
Incorrect (full-resolution image for small display):
import { Image } from 'expo-image'
function UserAvatar({ user }) {
return (
<Image
source={{ uri: user.profilePhotoUrl }} // 4032x3024 original
style={{ width: 48, height: 48 }}
// 35MB in memory for a tiny avatar
/>
)
}Correct (request resized image):
import { Image } from 'expo-image'
import { PixelRatio } from 'react-native'
function UserAvatar({ user }) {
// Request image at actual pixel size needed
const size = 48 * PixelRatio.get() // 144px on 3x device
const resizedUrl = `${user.profilePhotoUrl}?w=${size}&h=${size}&fit=cover`
return (
<Image
source={{ uri: resizedUrl }}
style={{ width: 48, height: 48 }}
contentFit="cover"
/>
)
}For local images, use ImageManipulator:
import * as ImageManipulator from 'expo-image-manipulator'
async function resizeImage(uri, targetWidth, targetHeight) {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: targetWidth, height: targetHeight } }],
{ compress: 0.8, format: ImageManipulator.SaveFormat.JPEG }
)
return result.uri
}Image CDN URL patterns:
// Cloudinary
`https://res.cloudinary.com/demo/image/upload/w_100,h_100,c_fill/${imageId}`
// Imgix
`https://example.imgix.net/image.jpg?w=100&h=100&fit=crop`
// AWS CloudFront + Lambda@Edge
`${baseUrl}?width=100&height=100`Note: Always account for pixel density with PixelRatio.get() to avoid blurry images on high-DPI screens.
Reference: expo-image-manipulator Documentation
Use expo-image Instead of React Native Image
expo-image provides built-in disk and memory caching, placeholder support, and uses performant native libraries (SDWebImage on iOS, Glide on Android) under the hood.
Incorrect (React Native Image without caching):
import { Image, View } from 'react-native'
function UserAvatar({ user }) {
return (
<View>
<Image
source={{ uri: user.avatarUrl }}
style={{ width: 48, height: 48, borderRadius: 24 }}
/>
</View>
)
}
// No disk caching - reloads on every mount
// No placeholder - shows nothing while loadingCorrect (expo-image with caching and placeholder):
import { Image } from 'expo-image'
import { View } from 'react-native'
const blurhash = 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'
function UserAvatar({ user }) {
return (
<View>
<Image
source={{ uri: user.avatarUrl }}
placeholder={{ blurhash }}
contentFit="cover"
transition={200}
style={{ width: 48, height: 48, borderRadius: 24 }}
cachePolicy="disk"
/>
</View>
)
}Installation:
npx expo install expo-imageCache policies:
// Memory only - fastest, clears on app close
cachePolicy="memory"
// Disk - persists across sessions
cachePolicy="disk"
// Memory and disk - best of both
cachePolicy="memory-disk"
// No caching - always fetch
cachePolicy="none"Benefits over React Native Image:
- Automatic disk and memory caching
- BlurHash/ThumbHash placeholders
- Automatic resizing to container size
- Better memory management
- Animated image support (GIF, WebP)
Reference: expo-image Documentation
Use BlurHash or ThumbHash Placeholders
Show a compact placeholder while images load to prevent layout shift and improve perceived performance. BlurHash encodes an image preview in ~20-30 characters.
Incorrect (no placeholder, content jumps):
import { Image } from 'expo-image'
function ProductCard({ product }) {
return (
<View>
<Image
source={{ uri: product.imageUrl }}
style={{ width: 200, height: 200 }}
// Blank space, then sudden image appearance
/>
<Text>{product.name}</Text>
</View>
)
}Correct (BlurHash placeholder):
import { Image } from 'expo-image'
function ProductCard({ product }) {
return (
<View>
<Image
source={{ uri: product.imageUrl }}
placeholder={{ blurhash: product.blurhash }}
contentFit="cover"
transition={300}
style={{ width: 200, height: 200 }}
/>
<Text>{product.name}</Text>
</View>
)
}Generate BlurHash on backend:
// Node.js with sharp and blurhash
import { encode } from 'blurhash'
import sharp from 'sharp'
async function generateBlurhash(imagePath) {
const { data, info } = await sharp(imagePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: 'inside' })
.toBuffer({ resolveWithObject: true })
return encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // x components
3 // y components
)
}
// Returns: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"ThumbHash alternative (preserves aspect ratio):
import { Image } from 'expo-image'
function ProductCard({ product }) {
return (
<Image
source={{ uri: product.imageUrl }}
placeholder={{ thumbhash: product.thumbhash }}
style={{ width: 200, height: 200 }}
/>
)
}Include hash in API response:
{
"id": "prod_123",
"name": "Running Shoes",
"imageUrl": "https://cdn.example.com/shoes.webp",
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
}Use WebP Format for Smaller File Sizes
WebP provides superior compression compared to JPEG and PNG while maintaining quality. Both iOS and Android fully support WebP.
Incorrect (unoptimized PNG/JPEG):
import { Image } from 'expo-image'
function ProductGallery({ images }) {
return (
<View>
{images.map(img => (
<Image
key={img.id}
source={{ uri: img.pngUrl }} // 500KB PNG
style={styles.productImage}
/>
))}
</View>
)
}Correct (WebP format):
import { Image } from 'expo-image'
function ProductGallery({ images }) {
return (
<View>
{images.map(img => (
<Image
key={img.id}
source={{ uri: img.webpUrl }} // 175KB WebP (same quality)
style={styles.productImage}
/>
))}
</View>
)
}Convert local assets to WebP:
# Using cwebp (install via Homebrew: brew install webp)
cwebp -q 80 input.png -o output.webp
# Batch convert
for f in assets/images/*.png; do
cwebp -q 80 "$f" -o "${f%.png}.webp"
doneRequest WebP from image CDN:
// Cloudinary - automatic format selection
`https://res.cloudinary.com/demo/image/upload/f_auto/${imageId}`
// Imgix
`https://example.imgix.net/image.jpg?fm=webp&q=80`
// Direct WebP URL
`${baseUrl}/images/${imageId}.webp`Format comparison:
| Format | 1000x1000 Photo | Transparency |
|---|---|---|
| PNG | 2.5 MB | Yes |
| JPEG | 150 KB | No |
| WebP | 100 KB | Yes |
Note: For animated images, WebP also outperforms GIF with smaller file sizes and better quality.
Reference: WebP Documentation
Defer Non-Critical Initialization Until After First Render
Analytics, crash reporting, and other non-critical services should initialize after the first meaningful render. This prioritizes showing content to the user.
Incorrect (all initialization blocks startup):
import { useEffect } from 'react'
import * as Analytics from 'expo-analytics'
import * as Sentry from '@sentry/react-native'
import { initializeDatabase } from './database'
import { syncOfflineData } from './sync'
export default function App() {
useEffect(() => {
// All of this runs before user sees anything
Analytics.initialize('key')
Sentry.init({ dsn: 'dsn' })
initializeDatabase()
syncOfflineData()
}, [])
return <HomeScreen />
}Correct (deferred initialization):
import { useEffect } from 'react'
import { InteractionManager } from 'react-native'
import * as Analytics from 'expo-analytics'
import * as Sentry from '@sentry/react-native'
import { initializeDatabase } from './database'
import { syncOfflineData } from './sync'
export default function App() {
useEffect(() => {
// Defer non-critical work until after animations complete
const task = InteractionManager.runAfterInteractions(() => {
Analytics.initialize('key')
Sentry.init({ dsn: 'dsn' })
initializeDatabase()
syncOfflineData()
})
return () => task.cancel()
}, [])
return <HomeScreen />
}Alternative (setTimeout for simple cases):
useEffect(() => {
const timer = setTimeout(() => {
Analytics.initialize('key')
}, 1000) // Delay 1 second after mount
return () => clearTimeout(timer)
}, [])Reference: React Native InteractionManager
Use Hermes Engine for Faster Startup
Hermes compiles JavaScript to bytecode at build time, dramatically reducing startup time and memory usage. It is enabled by default in Expo SDK 52+ but verify your configuration.
Incorrect (Hermes disabled or not configured):
{
"expo": {
"name": "MyApp",
"jsEngine": "jsc"
}
}Correct (Hermes enabled):
{
"expo": {
"name": "MyApp",
"jsEngine": "hermes"
}
}Verification (check Hermes is active):
import { Platform } from 'react-native'
const isHermes = () => !!global.HermesInternal
console.log('Hermes enabled:', isHermes())
// Should log: Hermes enabled: trueBenefits:
- Precompiled bytecode eliminates JavaScript parsing at startup
- Memory-mapped bytecode reduces RAM usage
- Faster garbage collection optimized for mobile
Note: Hermes is the default engine starting from React Native 0.70 and Expo SDK 47+. If you're on an older version, explicitly enable it.
Reference: React Native Hermes Documentation
Minimize Imports in Root App Component
The root App component and its imports are parsed synchronously at startup. Move heavy imports to screens that need them and use dynamic imports for non-critical features.
Incorrect (heavy imports in root):
// App.tsx - all imports parsed at startup
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import HomeScreen from './screens/HomeScreen'
import ProfileScreen from './screens/ProfileScreen'
import SettingsScreen from './screens/SettingsScreen'
import AdminDashboard from './screens/AdminDashboard'
import AnalyticsScreen from './screens/AnalyticsScreen'
import { Chart } from 'react-native-charts-wrapper' // Heavy library
import { Editor } from '@monaco-editor/react' // Heavy library
const Stack = createNativeStackNavigator()
export default function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
{/* ... many more screens */}
</Stack.Navigator>
</NavigationContainer>
)
}Correct (lazy imports for non-critical screens):
// App.tsx - minimal imports
import { lazy, Suspense } from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import HomeScreen from './screens/HomeScreen'
import LoadingScreen from './components/LoadingScreen'
const ProfileScreen = lazy(() => import('./screens/ProfileScreen'))
const SettingsScreen = lazy(() => import('./screens/SettingsScreen'))
const AdminDashboard = lazy(() => import('./screens/AdminDashboard'))
const Stack = createNativeStackNavigator()
export default function App() {
return (
<NavigationContainer>
<Suspense fallback={<LoadingScreen />}>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Admin" component={AdminDashboard} />
</Stack.Navigator>
</Suspense>
</NavigationContainer>
)
}Note: With Hermes and memory-mapped bytecode, the benefit of code splitting is reduced compared to web. Focus on deferring heavy libraries like chart libraries and rich text editors.
Enable New Architecture for Synchronous Native Communication
The New Architecture replaces the async JSON bridge with JSI (JavaScript Interface), enabling synchronous communication between JavaScript and native code. This eliminates serialization overhead and reduces startup latency.
Incorrect (old architecture with bridge):
{
"expo": {
"name": "MyApp",
"newArchEnabled": false
}
}Correct (New Architecture enabled):
{
"expo": {
"name": "MyApp",
"newArchEnabled": true
}
}For bare React Native projects:
// android/gradle.properties
newArchEnabled=true
// ios/Podfile
ENV['RCT_NEW_ARCH_ENABLED'] = '1'Benefits:
- Direct JavaScript to native calls without JSON serialization
- Lazy loading of native modules (TurboModules)
- Concurrent rendering support (Fabric)
- Synchronous layout access eliminates flickering
When NOT to enable:
- Legacy third-party libraries without New Architecture support
- Apps using native modules not yet migrated to TurboModules
- When testing reveals regressions in specific native functionality
Note: New Architecture is enabled by default in Expo SDK 52+ for new projects. Existing projects may need migration. Check library compatibility before enabling.
Reference: React Native New Architecture
Preload Critical Assets During Splash Screen
Load fonts, icons, and critical images while the splash screen is visible. This ensures assets are ready when the first screen renders, avoiding layout shifts and missing content.
Incorrect (assets load after render):
import { useEffect, useState } from 'react'
import { Image, View } from 'react-native'
export default function ProfileScreen() {
const [profile, setProfile] = useState(null)
useEffect(() => {
fetchProfile().then(setProfile)
}, [])
return (
<View>
<Image source={{ uri: profile?.avatarUrl }} /> {/* Loads on demand */}
</View>
)
}Correct (critical assets preloaded):
import { useEffect, useState, useCallback } from 'react'
import { Image, View } from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import * as Font from 'expo-font'
import { Asset } from 'expo-asset'
SplashScreen.preventAutoHideAsync()
async function loadResourcesAsync() {
await Promise.all([
Font.loadAsync({
'Inter-Bold': require('./assets/fonts/Inter-Bold.ttf'),
}),
Asset.loadAsync([
require('./assets/images/logo.png'),
require('./assets/images/placeholder-avatar.png'),
]),
])
}
export default function App() {
const [resourcesLoaded, setResourcesLoaded] = useState(false)
useEffect(() => {
loadResourcesAsync().then(() => setResourcesLoaded(true))
}, [])
const onLayoutRootView = useCallback(async () => {
if (resourcesLoaded) {
await SplashScreen.hideAsync()
}
}, [resourcesLoaded])
if (!resourcesLoaded) return null
return <RootNavigator onLayout={onLayoutRootView} />
}Reference: Expo Asset Documentation
Control Splash Screen Visibility During Asset Loading
Keep the native splash screen visible while loading critical assets like fonts and images. This prevents the jarring white flash users see when the app renders before assets are ready.
Incorrect (splash hides before assets load):
import { useEffect, useState } from 'react'
import { View, Text } from 'react-native'
export default function App() {
const [fontsLoaded, setFontsLoaded] = useState(false)
useEffect(() => {
loadFonts().then(() => setFontsLoaded(true))
}, [])
if (!fontsLoaded) {
return null // White screen while fonts load
}
return <HomeScreen />
}Correct (splash stays until assets ready):
import { useEffect, useState, useCallback } from 'react'
import { View, Text } from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
SplashScreen.preventAutoHideAsync()
export default function App() {
const [fontsLoaded, setFontsLoaded] = useState(false)
useEffect(() => {
loadFonts().then(() => setFontsLoaded(true))
}, [])
const onLayoutRootView = useCallback(async () => {
if (fontsLoaded) {
await SplashScreen.hideAsync()
}
}, [fontsLoaded])
if (!fontsLoaded) {
return null // Splash screen still visible
}
return <HomeScreen onLayout={onLayoutRootView} />
}Reference: Expo SplashScreen Documentation
Avoid Inline Functions in List renderItem
Inline arrow functions in renderItem create new function instances on every parent render, causing all list items to re-render even when data hasn't changed.
Incorrect (inline function recreated every render):
import { FlashList } from '@shopify/flash-list'
function ProductList({ products, onAddToCart }) {
return (
<FlashList
data={products}
renderItem={({ item }) => (
<ProductCard
product={item}
onAddToCart={() => onAddToCart(item.id)} // New function every render
/>
)}
estimatedItemSize={120}
/>
)
}Correct (stable callback references):
import { useCallback } from 'react'
import { FlashList } from '@shopify/flash-list'
function ProductList({ products, onAddToCart }) {
const renderItem = useCallback(({ item }) => (
<ProductCard
product={item}
productId={item.id}
onAddToCart={onAddToCart}
/>
), [onAddToCart])
return (
<FlashList
data={products}
renderItem={renderItem}
estimatedItemSize={120}
/>
)
}
// ProductCard receives productId and calls onAddToCart(productId) internally
const ProductCard = memo(function ProductCard({ product, productId, onAddToCart }) {
const handlePress = useCallback(() => {
onAddToCart(productId)
}, [productId, onAddToCart])
return (
<Pressable onPress={handlePress}>
<Text>{product.name}</Text>
</Pressable>
)
})Alternative (extract to named component):
function ProductList({ products, onAddToCart }) {
return (
<FlashList
data={products}
renderItem={ProductCardRenderer}
extraData={onAddToCart}
estimatedItemSize={120}
/>
)
}
const ProductCardRenderer = memo(({ item, extraData }) => (
<ProductCard product={item} onAddToCart={extraData} />
))Avoid key Prop Inside FlashList Items
Using the key prop inside FlashList item components breaks cell recycling. FlashList needs to reuse component instances, but key forces React to create new instances.
Incorrect (key breaks recycling):
import { FlashList } from '@shopify/flash-list'
function ProductList({ products }) {
return (
<FlashList
data={products}
renderItem={({ item }) => (
<View key={item.id}> {/* Breaks recycling! */}
<ProductImage key={`img-${item.id}`} uri={item.imageUrl} />
<Text key={`name-${item.id}`}>{item.name}</Text>
<Text key={`price-${item.id}`}>{item.price}</Text>
</View>
)}
estimatedItemSize={120}
/>
)
}
// FlashList falls back to FlatList behavior, losing performance benefitsCorrect (no key props inside items):
import { FlashList } from '@shopify/flash-list'
function ProductList({ products }) {
return (
<FlashList
data={products}
renderItem={({ item }) => (
<View>
<ProductImage uri={item.imageUrl} />
<Text>{item.name}</Text>
<Text>{item.price}</Text>
</View>
)}
keyExtractor={item => item.id} // Use keyExtractor instead
estimatedItemSize={120}
/>
)
}
// Cell recycling works properlyWhen mapping child arrays, use index (acceptable in recycled context):
function ProductCard({ product }) {
return (
<View>
<Text>{product.name}</Text>
{product.tags.map((tag, index) => (
<Tag key={index} label={tag} /> // Index key OK for stable arrays
))}
</View>
)
}Note: This rule is specific to FlashList. In regular React components and FlatList, keys are important for reconciliation.
Reference: FlashList Performance Tips
Configure List Batch Rendering for Scroll Performance
Tune FlatList's batch rendering props to balance memory usage against scroll smoothness. The defaults are conservative and may show blank areas during fast scrolling.
Incorrect (default settings cause blank areas):
import { FlatList } from 'react-native'
function ArticleList({ articles }) {
return (
<FlatList
data={articles}
renderItem={({ item }) => <ArticleCard article={item} />}
keyExtractor={item => item.id}
// Default: windowSize=21, maxToRenderPerBatch=10
// May show blank areas on fast scroll
/>
)
}Correct (tuned for fast scrolling):
import { FlatList } from 'react-native'
function ArticleList({ articles }) {
return (
<FlatList
data={articles}
renderItem={({ item }) => <ArticleCard article={item} />}
keyExtractor={item => item.id}
windowSize={11} // Render 5 screens above and below
maxToRenderPerBatch={5} // Render 5 items per batch
updateCellsBatchingPeriod={50} // 50ms between batches
initialNumToRender={10} // Initial visible items
removeClippedSubviews={true} // Unmount off-screen items
/>
)
}Configuration guide:
| Prop | Lower Value | Higher Value |
|---|---|---|
| windowSize | Less memory, more blanks | More memory, fewer blanks |
| maxToRenderPerBatch | Smoother UI, more blanks | More blanks during scroll, faster fill |
| initialNumToRender | Faster initial render | Better initial scroll |
For different use cases:
// Memory-constrained (long lists, complex items)
windowSize={5}
maxToRenderPerBatch={3}
// Smooth scrolling priority (short lists, simple items)
windowSize={21}
maxToRenderPerBatch={10}Note: FlashList handles batching automatically and usually doesn't need these tweaks. Prefer FlashList for new code.
Reference: Optimizing FlatList Configuration
Memoize List Item Components
Wrap list item components in React.memo() to prevent re-rendering unchanged items when the list data updates. This is essential for smooth scrolling performance.
Incorrect (all items re-render on any change):
import { FlashList } from '@shopify/flash-list'
function MessageItem({ message, onDelete }) {
return (
<View style={styles.messageContainer}>
<Text style={styles.sender}>{message.sender}</Text>
<Text style={styles.content}>{message.content}</Text>
<Text style={styles.time}>{formatTime(message.timestamp)}</Text>
<IconButton icon="delete" onPress={() => onDelete(message.id)} />
</View>
)
}
function MessageList({ messages, onDeleteMessage }) {
return (
<FlashList
data={messages}
renderItem={({ item }) => (
<MessageItem message={item} onDelete={onDeleteMessage} />
)}
estimatedItemSize={80}
/>
)
}
// All MessageItems re-render when any message changesCorrect (memoized items only re-render when their props change):
import { memo, useCallback } from 'react'
import { FlashList } from '@shopify/flash-list'
const MessageItem = memo(function MessageItem({ message, onDelete }) {
return (
<View style={styles.messageContainer}>
<Text style={styles.sender}>{message.sender}</Text>
<Text style={styles.content}>{message.content}</Text>
<Text style={styles.time}>{formatTime(message.timestamp)}</Text>
<IconButton icon="delete" onPress={() => onDelete(message.id)} />
</View>
)
})
function MessageList({ messages, onDeleteMessage }) {
const renderItem = useCallback(({ item }) => (
<MessageItem message={item} onDelete={onDeleteMessage} />
), [onDeleteMessage])
return (
<FlashList
data={messages}
renderItem={renderItem}
estimatedItemSize={80}
/>
)
}
// Only changed MessageItems re-renderCustom comparison for complex items:
const MessageItem = memo(
function MessageItem({ message, onDelete }) {
// ... component body
},
(prevProps, nextProps) => {
// Only re-render if these specific fields change
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.isRead === nextProps.message.isRead
)
}
)Reference: React.memo Documentation
Provide Accurate estimatedItemSize for FlashList
FlashList uses estimatedItemSize to pre-calculate scroll positions and optimize rendering. An accurate estimate prevents layout jumps and improves initial render performance.
Incorrect (missing or inaccurate estimate):
import { FlashList } from '@shopify/flash-list'
function MessageList({ messages }) {
return (
<FlashList
data={messages}
renderItem={({ item }) => <MessageBubble message={item} />}
// Missing estimatedItemSize - FlashList will warn
/>
)
}Correct (measured estimate):
import { FlashList } from '@shopify/flash-list'
function MessageList({ messages }) {
return (
<FlashList
data={messages}
renderItem={({ item }) => <MessageBubble message={item} />}
estimatedItemSize={85} // Measured average height
/>
)
}
// How to measure: render a few items and log their heights
// <View onLayout={(e) => console.log(e.nativeEvent.layout.height)}>For variable height items, use getItemType:
import { FlashList } from '@shopify/flash-list'
function FeedList({ items }) {
return (
<FlashList
data={items}
renderItem={({ item }) => {
if (item.type === 'image') return <ImagePost post={item} />
if (item.type === 'video') return <VideoPost post={item} />
return <TextPost post={item} />
}}
getItemType={item => item.type}
estimatedItemSize={200} // Average across all types
/>
)
}Note: FlashList v2+ with New Architecture handles item sizing automatically. This rule applies to FlashList v1 or when using legacyImplementation. Check performance warnings in development mode for sizing issues.
Reference: FlashList estimatedItemSize
Provide getItemLayout for Fixed-Height FlatList Items
When all list items have the same height, provide getItemLayout to skip asynchronous layout measurement. This enables instant scrollToIndex and prevents blank areas during fast scrolling.
Incorrect (async layout measurement):
import { FlatList } from 'react-native'
const ITEM_HEIGHT = 72
function ContactList({ contacts }) {
const listRef = useRef(null)
const scrollToContact = (index) => {
listRef.current?.scrollToIndex({ index, animated: true })
// May fail or show blank area while measuring
}
return (
<FlatList
ref={listRef}
data={contacts}
renderItem={({ item }) => (
<ContactRow contact={item} style={{ height: ITEM_HEIGHT }} />
)}
keyExtractor={item => item.id}
/>
)
}Correct (pre-calculated layout):
import { FlatList } from 'react-native'
const ITEM_HEIGHT = 72
function ContactList({ contacts }) {
const listRef = useRef(null)
const getItemLayout = useCallback((data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}), [])
const scrollToContact = (index) => {
listRef.current?.scrollToIndex({ index, animated: true })
// Instant scroll, no measurement needed
}
return (
<FlatList
ref={listRef}
data={contacts}
renderItem={({ item }) => (
<ContactRow contact={item} style={{ height: ITEM_HEIGHT }} />
)}
keyExtractor={item => item.id}
getItemLayout={getItemLayout}
/>
)
}With separators:
const ITEM_HEIGHT = 72
const SEPARATOR_HEIGHT = 1
const getItemLayout = (data, index) => ({
length: ITEM_HEIGHT,
offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
index,
})Note: Only use getItemLayout when ALL items have the exact same height. For variable heights, use FlashList with estimatedItemSize instead.
Reference: React Native FlatList getItemLayout
Use FlashList Instead of FlatList for Large Lists
FlashList uses cell recycling like native iOS UITableView and Android RecyclerView, dramatically reducing memory usage and improving scroll performance compared to FlatList's virtualization.
Incorrect (FlatList for large datasets):
import { FlatList } from 'react-native'
function ProductList({ products }) {
return (
<FlatList
data={products} // 1000+ items
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={item => item.id}
/>
)
}
// FlatList mounts/unmounts components, causing jank during fast scrollingCorrect (FlashList with cell recycling):
import { FlashList } from '@shopify/flash-list'
function ProductList({ products }) {
return (
<FlashList
data={products}
renderItem={({ item }) => <ProductCard product={item} />}
estimatedItemSize={120} // Required: approximate item height
keyExtractor={item => item.id}
/>
)
}
// FlashList reuses components, maintaining smooth 60fpsInstallation:
npx expo install @shopify/flash-listPerformance metrics (FlashList v1 benchmarks):
| Metric | FlatList | FlashList |
|---|---|---|
| Average FPS | 36.9 | 56.9 |
| CPU Usage | 198.9% | 36.5% |
| JS Thread | >90% | <10% |
Note: FlashList v2+ (2025) with New Architecture provides further improvements and automatic item sizing.
When to use FlatList:
- Lists with < 50 simple items
- Highly dynamic item heights that can't be estimated
- When FlashList compatibility issues arise
Reference: FlashList Documentation
Abort Fetch Requests on Component Unmount
Use AbortController to cancel in-flight fetch requests when components unmount. This prevents memory leaks and "setState on unmounted component" warnings.
Incorrect (fetch continues after unmount):
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadUser = async () => {
setLoading(true)
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
setUser(data) // May update unmounted component
setLoading(false)
}
loadUser()
}, [userId])
if (loading) return <LoadingSpinner />
return <ProfileCard user={user} />
}
// If user navigates away quickly, setState called on unmounted componentCorrect (aborts fetch on unmount):
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const abortController = new AbortController()
const loadUser = async () => {
setLoading(true)
try {
const response = await fetch(`/api/users/${userId}`, {
signal: abortController.signal,
})
const data = await response.json()
setUser(data)
setLoading(false)
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch failed:', error)
setLoading(false)
}
// Ignore AbortError - expected on unmount
}
}
loadUser()
return () => abortController.abort()
}, [userId])
if (loading) return <LoadingSpinner />
return <ProfileCard user={user} />
}With TanStack Query (handles automatically):
import { useQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/users/${userId}`, { signal })
return response.json()
},
})
if (isLoading) return <LoadingSpinner />
return <ProfileCard user={user} />
}
// TanStack Query handles abort automaticallyCustom hook for fetch with abort:
function useFetch(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
setLoading(true)
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err)
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [url])
return { data, loading, error }
}Reference: AbortController MDN
Avoid Closure-Based Memory Leaks in Callbacks
Closures in callbacks can retain references to component state and props, preventing garbage collection even after unmount. Use refs for values needed in long-lived callbacks.
Incorrect (closure retains entire component scope):
function NotificationHandler({ userId, onNotification }) {
const [notifications, setNotifications] = useState([])
useEffect(() => {
const handleNotification = (notification) => {
// Closure captures entire component scope
console.log(`User ${userId} received:`, notification)
setNotifications(prev => [...prev, notification])
onNotification(notification)
}
const subscription = NotificationService.subscribe(handleNotification)
return () => subscription.unsubscribe()
}, [userId, onNotification]) // Recreated on every userId change
return <NotificationList notifications={notifications} />
}Correct (refs for stable callback values):
function NotificationHandler({ userId, onNotification }) {
const [notifications, setNotifications] = useState([])
const userIdRef = useRef(userId)
const onNotificationRef = useRef(onNotification)
// Keep refs updated
useEffect(() => {
userIdRef.current = userId
}, [userId])
useEffect(() => {
onNotificationRef.current = onNotification
}, [onNotification])
useEffect(() => {
const handleNotification = (notification) => {
// Refs don't cause recreation, minimal closure
console.log(`User ${userIdRef.current} received:`, notification)
setNotifications(prev => [...prev, notification])
onNotificationRef.current(notification)
}
const subscription = NotificationService.subscribe(handleNotification)
return () => subscription.unsubscribe()
}, []) // Stable effect, never recreated
return <NotificationList notifications={notifications} />
}useLatest hook pattern:
function useLatest(value) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
function NotificationHandler({ userId, onNotification }) {
const userIdRef = useLatest(userId)
const onNotificationRef = useLatest(onNotification)
useEffect(() => {
const handleNotification = (notification) => {
console.log(`User ${userIdRef.current} received:`, notification)
onNotificationRef.current(notification)
}
const subscription = NotificationService.subscribe(handleNotification)
return () => subscription.unsubscribe()
}, [])
// ...
}When to use this pattern:
- Event listeners that outlive renders
- WebSocket message handlers
- Push notification callbacks
- Background task callbacks
Clean Up Subscriptions and Timers in useEffect
Always return a cleanup function from useEffect when creating subscriptions, timers, or event listeners. Failing to clean up causes memory leaks that compound over time.
Incorrect (no cleanup, memory leak):
function LocationTracker() {
const [location, setLocation] = useState(null)
useEffect(() => {
// Subscription never removed on unmount
Location.watchPositionAsync(
{ accuracy: Location.Accuracy.High },
(newLocation) => setLocation(newLocation)
)
}, [])
return <MapView location={location} />
}
// Location updates continue after component unmountsCorrect (cleanup on unmount):
function LocationTracker() {
const [location, setLocation] = useState(null)
useEffect(() => {
let subscription
const startTracking = async () => {
subscription = await Location.watchPositionAsync(
{ accuracy: Location.Accuracy.High },
(newLocation) => setLocation(newLocation)
)
}
startTracking()
return () => {
subscription?.remove() // Clean up on unmount
}
}, [])
return <MapView location={location} />
}Timer cleanup:
function AutoRefresh({ onRefresh }) {
useEffect(() => {
const intervalId = setInterval(() => {
onRefresh()
}, 30000)
return () => clearInterval(intervalId) // Clean up timer
}, [onRefresh])
return null
}Event listener cleanup:
function KeyboardAwareView({ children }) {
const [keyboardHeight, setKeyboardHeight] = useState(0)
useEffect(() => {
const showSubscription = Keyboard.addListener('keyboardDidShow', (e) => {
setKeyboardHeight(e.endCoordinates.height)
})
const hideSubscription = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardHeight(0)
})
return () => {
showSubscription.remove()
hideSubscription.remove()
}
}, [])
return <View style={{ paddingBottom: keyboardHeight }}>{children}</View>
}Common cleanup needs:
- WebSocket connections
- Location/sensor subscriptions
- Keyboard listeners
- App state listeners
- setInterval/setTimeout
- Notification listeners
Reference: useEffect Cleanup Documentation
Profile Memory Usage with Development Tools
Use React Native DevTools, Flipper, and Hermes profiling to identify memory leaks and excessive allocations before they cause production crashes.
Incorrect (no memory monitoring, leaks ship to production):
function App() {
// No memory profiling setup
// Memory leaks accumulate undetected
// Users experience crashes after prolonged use
return <RootNavigator />
}Correct (proactive memory monitoring):
import { useEffect } from 'react'
function App() {
useMemoryMonitor() // Development-only memory tracking
return <RootNavigator />
}
function useMemoryMonitor() {
useEffect(() => {
if (__DEV__ && global.HermesInternal) {
const interval = setInterval(() => {
const stats = global.HermesInternal.getHeapStatistics()
console.log('Memory:', Math.round(stats.heapSize / 1024 / 1024) + 'MB')
}, 5000)
return () => clearInterval(interval)
}
}, [])
}Setup React Native DevTools:
# Open DevTools from Metro terminal
j # Press 'j' to open React Native DevTools
# Or use the command
npx react-native doctorEnable Performance Monitor overlay:
import { PerformanceMonitor } from 'react-native'
// In development, enable performance overlay
if (__DEV__) {
// Shake device or Cmd+D to open menu
// Select "Show Performance Monitor"
}Profile with Hermes:
// Take a heap snapshot
const Hermes = global.HermesInternal
if (Hermes) {
// Enable sampling profiler
Hermes.enableSamplingProfiler()
// Later, capture profile
const profile = Hermes.stopSamplingProfiler()
console.log('Heap stats:', Hermes.getHeapStatistics())
}Memory monitoring in CI:
// Add to your app for memory tracking
function useMemoryMonitor() {
useEffect(() => {
if (__DEV__) {
const interval = setInterval(() => {
if (global.HermesInternal) {
const stats = global.HermesInternal.getHeapStatistics()
console.log('Memory:', {
used: Math.round(stats.heapSize / 1024 / 1024) + 'MB',
allocated: Math.round(stats.allocatedBytes / 1024 / 1024) + 'MB',
})
}
}, 5000)
return () => clearInterval(interval)
}
}, [])
}What to look for:
| Issue | Symptom | Tool |
|---|---|---|
| Memory leak | Heap grows over time | Hermes heap snapshot |
| Excessive re-renders | High JS frame time | React Profiler |
| Large objects | Spikes in allocation | Memory timeline |
| Retained views | View count grows | Flipper Layout Inspector |
Flipper plugins for memory:
- LeakCanary (Android) - automatic leak detection
- Memory plugin - heap visualization
- React DevTools - component memory
Pre-release checklist: 1. Navigate through all screens multiple times 2. Monitor memory for growth 3. Force garbage collection and check for retained objects 4. Test with limited memory (Android emulator settings)
Reference: React Native DevTools
Release Heavy Resources When Not Needed
Explicitly release camera, video players, maps, and other heavy native resources when components are hidden or backgrounded. These don't automatically garbage collect.
Incorrect (camera stays active when hidden):
function CameraScreen() {
const [hasPermission, setHasPermission] = useState(null)
useEffect(() => {
Camera.requestCameraPermissionsAsync().then(({ status }) => {
setHasPermission(status === 'granted')
})
}, [])
if (!hasPermission) return <PermissionRequest />
return (
<Camera style={styles.camera} type={Camera.Constants.Type.back}>
<CameraOverlay />
</Camera>
)
}
// Camera continues running when navigating awayCorrect (release camera when not visible):
import { useFocusEffect } from '@react-navigation/native'
function CameraScreen() {
const [hasPermission, setHasPermission] = useState(null)
const [isFocused, setIsFocused] = useState(true)
useEffect(() => {
Camera.requestCameraPermissionsAsync().then(({ status }) => {
setHasPermission(status === 'granted')
})
}, [])
useFocusEffect(
useCallback(() => {
setIsFocused(true)
return () => setIsFocused(false) // Release when screen loses focus
}, [])
)
if (!hasPermission) return <PermissionRequest />
if (!isFocused) return <View style={styles.camera} /> // Placeholder
return (
<Camera style={styles.camera} type={Camera.Constants.Type.back}>
<CameraOverlay />
</Camera>
)
}Video player resource management:
import { useVideoPlayer, VideoView } from 'expo-video'
import { useFocusEffect } from '@react-navigation/native'
function VideoScreen({ videoUrl }) {
const player = useVideoPlayer(videoUrl)
useFocusEffect(
useCallback(() => {
// Resume when focused
player.play()
return () => {
// Pause and release when unfocused
player.pause()
}
}, [player])
)
return <VideoView player={player} style={styles.video} />
}App state handling for background:
import { AppState } from 'react-native'
function MediaPlayer({ source }) {
const playerRef = useRef(null)
const appState = useRef(AppState.currentState)
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (appState.current === 'active' && nextAppState.match(/inactive|background/)) {
playerRef.current?.pause()
}
appState.current = nextAppState
})
return () => subscription.remove()
}, [])
return <Video ref={playerRef} source={source} />
}Heavy resources to manage:
- Camera preview
- Video players
- Audio sessions
- Map views
- WebGL contexts
- Large image caches
Reference: Expo Camera Documentation
Avoid Deeply Nested Navigators
Each navigation layer adds overhead. Keep navigation structure flat where possible and avoid nesting navigators more than 2-3 levels deep.
Incorrect (deeply nested navigators):
// 4+ levels of nesting
function App() {
return (
<NavigationContainer>
<Drawer.Navigator> {/* Level 1 */}
<Drawer.Screen name="Main">
{() => (
<Tab.Navigator> {/* Level 2 */}
<Tab.Screen name="Home">
{() => (
<Stack.Navigator> {/* Level 3 */}
<Stack.Screen name="Feed">
{() => (
<Stack.Navigator> {/* Level 4 - excessive */}
<Stack.Screen name="Post" component={PostScreen} />
</Stack.Navigator>
)}
</Stack.Screen>
</Stack.Navigator>
)}
</Tab.Screen>
</Tab.Navigator>
)}
</Drawer.Screen>
</Drawer.Navigator>
</NavigationContainer>
)
}Correct (flattened structure):
// app/_layout.tsx - Root with tabs
import { Tabs } from 'expo-router'
export default function RootLayout() {
return (
<Tabs>
<Tabs.Screen name="(home)" />
<Tabs.Screen name="(search)" />
<Tabs.Screen name="(profile)" />
</Tabs>
)
}
// app/(home)/_layout.tsx - Stack within tab
import { Stack } from 'expo-router'
export default function HomeLayout() {
return (
<Stack>
<Stack.Screen name="index" />
<Stack.Screen name="post/[id]" />
<Stack.Screen name="user/[id]" />
</Stack>
)
}Use modal presentation instead of nested stacks:
// app/_layout.tsx
import { Stack } from 'expo-router'
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{ presentation: 'modal' }}
/>
<Stack.Screen
name="fullscreen"
options={{ presentation: 'fullScreenModal' }}
/>
</Stack>
)
}Navigation architecture guidelines:
- Root: Tabs or Drawer (1 level)
- Per-tab: Stack (2 levels total)
- Modals: Separate from tabs (parallel, not nested)
- Maximum recommended: 3 levels
Reference: Expo Router Navigation Patterns
Optimize Screen Options to Reduce Navigation Overhead
Define screen options statically when possible and avoid inline functions that cause re-renders. Dynamic headers should use minimal dependencies.
Incorrect (inline options cause re-renders):
import { Stack } from 'expo-router'
export default function Layout() {
const theme = useTheme()
return (
<Stack>
<Stack.Screen
name="profile"
options={{
headerStyle: { backgroundColor: theme.colors.primary },
headerTintColor: theme.colors.text,
headerTitle: () => <CustomTitle />, // Recreated every render
}}
/>
</Stack>
)
}Correct (stable options with memoization):
import { Stack } from 'expo-router'
import { useMemo } from 'react'
export default function Layout() {
const theme = useTheme()
const profileOptions = useMemo(() => ({
headerStyle: { backgroundColor: theme.colors.primary },
headerTintColor: theme.colors.text,
headerTitle: CustomTitle, // Component reference, not inline function
}), [theme.colors.primary, theme.colors.text])
return (
<Stack>
<Stack.Screen name="profile" options={profileOptions} />
</Stack>
)
}Define static options in screen file:
// app/profile.tsx
import { Stack } from 'expo-router'
export default function ProfileScreen() {
return (
<>
<Stack.Screen
options={{
title: 'Profile',
headerLargeTitle: true,
}}
/>
<ProfileContent />
</>
)
}Dynamic titles with navigation.setOptions:
function ProductScreen() {
const { id } = useLocalSearchParams()
const { data: product } = useQuery(['product', id], fetchProduct)
const navigation = useNavigation()
useEffect(() => {
if (product) {
navigation.setOptions({ title: product.name })
}
}, [product?.name, navigation])
return <ProductDetail product={product} />
}Avoid in screen options:
- Inline arrow functions
- Object literals (create new reference each render)
- Heavy computations
- Hooks (use parent component instead)
Related skills
How it compares
Pick expo over generic React skills when optimizing mobile-specific startup, list virtualization, and Expo bundle patterns.
FAQ
How many rules does the expo skill include?
The expo skill from pproenca/dot-skills includes 42 performance rules organized into 8 categories such as launch time, bundle size, list virtualization, and image optimization, each prioritized by user impact.
When does the expo skill activate?
The expo skill triggers on tasks involving Expo React Native components, navigation, lists, images, animations, bundle optimization, or mobile performance improvements during writing, review, or refactoring.