
React Native Best Practices
- 21k installs
- 1.6k repo stars
- Updated July 25, 2026
- callstackincubator/agent-skills
react-native-best-practices is a Callstack performance skill that applies React Native optimization patterns for developers who need to fix jank, bundle bloat, memory leaks, and slow time-to-interactive.
About
react-native-best-practices is a Callstack performance skill drawn from the Ultimate Guide to React Native Optimization. It covers JavaScript and React tuning, native iOS and Android optimizations, and bundling improvements for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Topics include Hermes optimization, JS thread blocking, bridge overhead, FlashList usage, native modules, and debugging frame drops. Developers reach for react-native-best-practices when production mobile apps show jank, bloated bundles, or memory growth under real device load.
- Impact ratings: CRITICAL, HIGH, MEDIUM for every rule
- Quick Pattern format with incorrect/correct code snippets
- Covers JS thread, Hermes, bridge overhead, FlashList, and native modules
React Native Best Practices by the numbers
- 20,995 all-time installs (skills.sh)
- +710 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #28 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
react-native-best-practices capabilities & compatibility
- Use cases
- frontend
What react-native-best-practices says it does
Performance optimization guide for React Native applications, covering JavaScript/React, Native (iOS/Android), and bundling optimizations.
npx skills add https://github.com/callstackincubator/agent-skills --skill react-native-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 21k |
|---|---|
| repo stars | ★ 1.6k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 25, 2026 |
| Repository | callstackincubator/agent-skills ↗ |
How do you optimize React Native app performance?
Instantly access proven React Native performance patterns that eliminate jank, reduce bundle size, and prevent memory leaks.
Who is it for?
React Native and Expo developers diagnosing FPS drops, slow TTI, large bundles, or memory leaks on real devices.
Skip if: Web-only React teams or greenfield projects with no performance symptoms yet and no React Native codebase.
When should I use this skill?
A developer reports React Native jank, frame drops, Hermes issues, FlashList slowness, or bundle size problems.
What you get
Profiling notes, Hermes and bundle tuning, FlashList list optimizations, and fixes for memory leaks and frame drops.
- performance tuning changes
- profiling checklist
- bundle and memory fixes
Files
React Native Best Practices
Overview
Performance optimization guide for React Native applications, covering JavaScript/React, Native (iOS/Android), and bundling optimizations. Based on Callstack's "Ultimate Guide to React Native Optimization".
Skill Format
Each reference file follows a hybrid format for fast lookup and deep understanding:
- Quick Pattern: Incorrect/Correct code snippets for immediate pattern matching
- Quick Command: Shell commands for process/measurement skills
- Quick Config: Configuration snippets for setup-focused skills
- Quick Reference: Summary tables for conceptual skills
- Deep Dive: Full context with When to Use, Prerequisites, Step-by-Step, Common Pitfalls
Impact ratings: CRITICAL (fix immediately), HIGH (significant improvement), MEDIUM (worthwhile optimization)
When to Apply
Reference these guidelines when:
- Debugging slow/janky UI or animations
- Investigating memory leaks (JS or native)
- Optimizing app startup time (TTI)
- Reducing bundle or app size
- Writing native modules (Turbo Modules)
- Profiling React Native performance
- Reviewing React Native code for performance
Security Notes
- Treat shell commands in these references as local developer operations. Review them before running, prefer version-pinned tooling, and avoid piping remote scripts directly to a shell.
- Treat third-party libraries and plugins as dependencies that still require normal supply-chain controls: pin versions, verify provenance, and update through your standard review process.
- Treat Re.Pack code splitting as first-party artifact delivery only. Remote chunks must come from trusted HTTPS origins you control and be pinned to the current app release.
Priority-Ordered Guidelines
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | FPS & Re-renders | CRITICAL | js-* |
| 2 | Bundle Size | CRITICAL | bundle-* |
| 3 | TTI Optimization | HIGH | native-*, bundle-* |
| 4 | Native Performance | HIGH | native-* |
| 5 | Memory Management | MEDIUM-HIGH | js-*, native-* |
| 6 | Animations | MEDIUM | js-* |
Quick Reference
Optimization Workflow
Follow this cycle for any performance issue: Measure → Optimize → Re-measure → Validate
1. Measure: Capture baseline metrics before changes. For runtime issues, prefer commit timeline, re-render counts, slow components, heaviest-commit breakdown, and startup/TTI when available. Component tree depth or count are optional context, not substitutes. 2. Optimize: Apply the targeted fix from the relevant reference 3. Re-measure: Run the same measurement to get updated metrics 4. Validate: Confirm improvement (e.g., FPS 45→60, TTI 3.2s→1.8s, bundle 2.1MB→1.6MB)
If metrics did not improve, revert and try the next suggested fix.
Review Guardrails
- Check library versions before suggesting API-specific fixes. Example: FlashList v2 deprecates
estimatedItemSize, so do not flag it as missing there. - Do not suggest
useMemooruseCallbackdependency changes unless behavior is demonstrably incorrect or profiling shows wasted work tied to that value. - Do not report stale closures speculatively. Show the stale read path, a repro, or profiler evidence before calling it out.
- When profiling a flow, measure the target interaction itself. Do not treat component tree depth or component count as the main performance evidence.
Critical: FPS & Re-renders
Profile first:
# Open React Native DevTools
# Press 'j' in Metro, or shake device → "Open DevTools"Common fixes:
- Replace ScrollView with FlatList/FlashList for lists
- Use React Compiler for automatic memoization
- Use atomic state (Jotai/Zustand) to reduce re-renders
- Use
useDeferredValuefor expensive computations
Critical: Bundle Size
Analyze bundle:
npx react-native bundle \
--entry-file index.js \
--bundle-output output.js \
--platform ios \
--sourcemap-output output.js.map \
--dev false --minify true
npx source-map-explorer output.js --no-border-checksVerify improvement after optimization:
# Record baseline size before changes
ls -lh output.js # e.g., Before: 2.1 MB
# After applying fixes, re-bundle and compare
npx react-native bundle --entry-file index.js --bundle-output output.js \
--platform ios --dev false --minify true
ls -lh output.js # e.g., After: 1.6 MB (24% reduction)Common fixes:
- Avoid barrel imports (import directly from source)
- Remove unnecessary Intl polyfills only after checking Hermes API and method coverage
- Enable tree shaking (Expo SDK 52+ or Re.Pack)
- Enable R8 for Android native code shrinking
High: TTI Optimization
Measure TTI:
- Use
react-native-performancefor markers - Only measure cold starts (exclude warm/hot/prewarm)
Common fixes:
- Disable JS bundle compression on Android (enables Hermes mmap)
- Use native navigation (react-native-screens)
- Preload commonly-used expensive screens before navigating to them
High: Native Performance
Profile native:
- iOS: Xcode Instruments → Time Profiler
- Android: Android Studio → CPU Profiler
Common fixes:
- Use background threads for heavy native work
- Prefer async over sync Turbo Module methods
- Use C++ for cross-platform performance-critical code
References
Full documentation with code examples in [references/][references]:
JavaScript/React (js-*)
| File | Impact | Description |
|---|---|---|
| [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] | CRITICAL | Replace ScrollView with virtualized lists |
| [js-profile-react.md][js-profile-react] | MEDIUM | React DevTools profiling |
| [js-measure-fps.md][js-measure-fps] | HIGH | FPS monitoring and measurement |
| [js-memory-leaks.md][js-memory-leaks] | MEDIUM | JS memory leak hunting |
| [js-atomic-state.md][js-atomic-state] | HIGH | Jotai/Zustand patterns |
| [js-concurrent-react.md][js-concurrent-react] | HIGH | useDeferredValue, useTransition |
| [js-react-compiler.md][js-react-compiler] | HIGH | Automatic memoization |
| [js-animations-reanimated.md][js-animations-reanimated] | MEDIUM | Reanimated worklets |
| [js-bottomsheet.md][js-bottomsheet] | HIGH | Bottom sheet optimization |
| [js-uncontrolled-components.md][js-uncontrolled-components] | HIGH | TextInput optimization |
Native (native-*)
| File | Impact | Description |
|---|---|---|
| [native-turbo-modules.md][native-turbo-modules] | HIGH | Building fast native modules |
| [native-sdks-over-polyfills.md][native-sdks-over-polyfills] | HIGH | Native vs JS libraries |
| [native-measure-tti.md][native-measure-tti] | HIGH | TTI measurement setup |
| [native-threading-model.md][native-threading-model] | HIGH | Turbo Module threads |
| [native-profiling.md][native-profiling] | MEDIUM | Xcode/Android Studio profiling |
| [native-platform-setup.md][native-platform-setup] | MEDIUM | iOS/Android tooling guide |
| [native-view-flattening.md][native-view-flattening] | MEDIUM | View hierarchy debugging |
| [native-memory-patterns.md][native-memory-patterns] | MEDIUM | C++/Swift/Kotlin memory |
| [native-memory-leaks.md][native-memory-leaks] | MEDIUM | Native memory leak hunting |
| [native-android-16kb-alignment.md][native-android-16kb-alignment] | CRITICAL | Third-party library alignment for Google Play |
Bundling (bundle-*)
| File | Impact | Description |
|---|---|---|
| [bundle-barrel-exports.md][bundle-barrel-exports] | CRITICAL | Avoid barrel imports |
| [bundle-analyze-js.md][bundle-analyze-js] | CRITICAL | JS bundle visualization |
| [bundle-tree-shaking.md][bundle-tree-shaking] | HIGH | Dead code elimination |
| [bundle-analyze-app.md][bundle-analyze-app] | HIGH | App size analysis |
| [bundle-r8-android.md][bundle-r8-android] | HIGH | Android code shrinking |
| [bundle-hermes-mmap.md][bundle-hermes-mmap] | HIGH | Disable bundle compression |
| [bundle-native-assets.md][bundle-native-assets] | HIGH | Asset catalog setup |
| [bundle-library-size.md][bundle-library-size] | MEDIUM | Evaluate dependencies |
| [bundle-code-splitting.md][bundle-code-splitting] | MEDIUM | Re.Pack code splitting |
Searching References
# Find patterns by keyword
grep -l "reanimated" references/
grep -l "flatlist" references/
grep -l "memory" references/
grep -l "profil" references/
grep -l "tti" references/
grep -l "bundle" references/Problem → Skill Mapping
| Problem | Start With |
|---|---|
| App feels slow/janky | [js-measure-fps.md][js-measure-fps] → [js-profile-react.md][js-profile-react] |
| Too many re-renders | [js-profile-react.md][js-profile-react] → [js-react-compiler.md][js-react-compiler] |
| Slow startup (TTI) | [native-measure-tti.md][native-measure-tti] → [bundle-analyze-js.md][bundle-analyze-js] |
| Large app size | [bundle-analyze-app.md][bundle-analyze-app] → [bundle-r8-android.md][bundle-r8-android] |
| Memory growing | [js-memory-leaks.md][js-memory-leaks] or [native-memory-leaks.md][native-memory-leaks] |
| Animation drops frames | [js-animations-reanimated.md][js-animations-reanimated] |
| Bottom sheet jank/re-renders | [js-bottomsheet.md][js-bottomsheet] → [js-animations-reanimated.md][js-animations-reanimated] |
| List scroll jank | [js-lists-flatlist-flashlist.md][js-lists-flatlist-flashlist] |
| TextInput lag | [js-uncontrolled-components.md][js-uncontrolled-components] |
| Native module slow | [native-turbo-modules.md][native-turbo-modules] → [native-threading-model.md][native-threading-model] |
| Native library alignment issue | [native-android-16kb-alignment.md][native-android-16kb-alignment] |
[references]: references/ [js-lists-flatlist-flashlist]: references/js-lists-flatlist-flashlist.md [js-profile-react]: references/js-profile-react.md [js-measure-fps]: references/js-measure-fps.md [js-memory-leaks]: references/js-memory-leaks.md [js-atomic-state]: references/js-atomic-state.md [js-concurrent-react]: references/js-concurrent-react.md [js-react-compiler]: references/js-react-compiler.md [js-animations-reanimated]: references/js-animations-reanimated.md [js-bottomsheet]: references/js-bottomsheet.md [js-uncontrolled-components]: references/js-uncontrolled-components.md [native-turbo-modules]: references/native-turbo-modules.md [native-sdks-over-polyfills]: references/native-sdks-over-polyfills.md [native-measure-tti]: references/native-measure-tti.md [native-threading-model]: references/native-threading-model.md [native-profiling]: references/native-profiling.md [native-platform-setup]: references/native-platform-setup.md [native-view-flattening]: references/native-view-flattening.md [native-memory-patterns]: references/native-memory-patterns.md [native-memory-leaks]: references/native-memory-leaks.md [native-android-16kb-alignment]: references/native-android-16kb-alignment.md [bundle-barrel-exports]: references/bundle-barrel-exports.md [bundle-analyze-js]: references/bundle-analyze-js.md [bundle-tree-shaking]: references/bundle-tree-shaking.md [bundle-analyze-app]: references/bundle-analyze-app.md [bundle-r8-android]: references/bundle-r8-android.md [bundle-hermes-mmap]: references/bundle-hermes-mmap.md [bundle-native-assets]: references/bundle-native-assets.md [bundle-library-size]: references/bundle-library-size.md [bundle-code-splitting]: references/bundle-code-splitting.md
Attribution
Based on "The Ultimate Guide to React Native Optimization" by Callstack.
interface:
display_name: "React Native Best Practices"
short_description: "React Native performance optimization guide"
default_prompt: "Use $react-native-best-practices to diagnose and improve React Native performance."
Onboarding
Step 1: Validate React Native Setup
Before applying performance optimizations, ensure:
- Expo CLI or React Native CLI is installed
- Verify with:
npx expo --versionandnpx react-native --version - Metro bundler is running (apply only for bundle analysis)
- React Native DevTools is available (apply only for profiling)
- Press 'j' in Metro terminal or shake device → "Open DevTools"
Security Guardrails
- Review shell commands before running them and prefer version-pinned tooling from trusted sources.
- Do not pipe remote install scripts directly into a shell.
- Treat third-party packages as normal supply-chain dependencies that require provenance and version review.
- If using Re.Pack code splitting, only load first-party chunks from trusted HTTPS origins tied to the current release.
When to Load Reference Files
Load specific reference files from references/ based on the task:
JavaScript/React Performance (js-*)
- Debugging slow/janky UI or animations →
references/js-measure-fps.md - Investigating re-render issues →
references/js-profile-react.md→references/js-react-compiler.md - Optimizing list scrolling →
references/js-lists-flatlist-flashlist.md - Reducing re-renders with state management →
references/js-atomic-state.md - Using Concurrent React features →
references/js-concurrent-react.md - Enabling automatic memoization →
references/js-react-compiler.md - Optimizing animations →
references/js-animations-reanimated.md - Fixing TextInput lag →
references/js-uncontrolled-components.md - Hunting JavaScript memory leaks →
references/js-memory-leaks.md
Native Performance (native-*)
- Measuring startup time (TTI) →
references/native-measure-tti.md - Building native modules →
references/native-turbo-modules.md - Understanding native threading →
references/native-threading-model.md - Profiling native code →
references/native-profiling.md - Setting up native tooling →
references/native-platform-setup.md - Debugging view hierarchy →
references/native-view-flattening.md - Native memory patterns →
references/native-memory-patterns.md - Hunting native memory leaks →
references/native-memory-leaks.md - Choosing native SDKs vs polyfills →
references/native-sdks-over-polyfills.md - Fixing Android 16KB alignment →
references/native-android-16kb-alignment.md
Bundle & App Size (bundle-*)
- Analyzing bundle size →
references/bundle-analyze-js.md - Analyzing app size →
references/bundle-analyze-app.md - Fixing barrel imports →
references/bundle-barrel-exports.md - Enabling tree shaking →
references/bundle-tree-shaking.md - Android code shrinking →
references/bundle-r8-android.md - Optimizing Hermes bundle loading →
references/bundle-hermes-mmap.md - Managing native assets →
references/bundle-native-assets.md - Evaluating library size →
references/bundle-library-size.md - Code splitting →
references/bundle-code-splitting.md
Problem → Reference Mapping
Use this quick lookup when debugging specific issues:
| Problem | Start With |
|---|---|
| App feels slow/janky | references/js-measure-fps.md → references/js-profile-react.md |
| Too many re-renders | references/js-profile-react.md → references/js-react-compiler.md |
| Slow startup (TTI) | references/native-measure-tti.md → references/bundle-analyze-js.md |
| Large app size | references/bundle-analyze-app.md → references/bundle-r8-android.md |
| Memory growing | references/js-memory-leaks.md or references/native-memory-leaks.md |
| Animation drops frames | references/js-animations-reanimated.md |
| List scroll jank | references/js-lists-flatlist-flashlist.md |
| TextInput lag | references/js-uncontrolled-components.md |
| Native module slow | references/native-turbo-modules.md → references/native-threading-model.md |
| Native library alignment issue | references/native-android-16kb-alignment.md |
Quick Reference Commands
FPS & Re-renders
# Open React Native DevTools
# Press 'j' in Metro, or shake device → "Open DevTools"Baseline runtime metrics should come from the target interaction itself:
- Capture commit timeline, re-render counts, slow components, and heaviest-commit breakdown.
- Treat component tree depth and count as supporting context only.
Common fixes:
- Replace ScrollView with FlatList/FlashList for lists
- Use React Compiler for automatic memoization
- Use atomic state (Jotai/Zustand) to reduce re-renders
- Use
useDeferredValuefor expensive computations
Review guardrails:
- Check library versions before suggesting API-specific fixes. FlashList v2 deprecates
estimatedItemSize. - Do not suggest
useMemooruseCallbackdependency changes without a reproducible correctness issue or profiling evidence. - Do not report stale closures unless the stale read path or repro is clear.
Analyze Bundle Size
npx react-native bundle \
--entry-file index.js \
--bundle-output output.js \
--platform ios \
--sourcemap-output output.js.map \
--dev false --minify true
npx source-map-explorer output.js --no-border-checksCommon fixes:
- Avoid barrel imports (import directly from source)
- Remove unnecessary Intl polyfills only after checking Hermes API and method coverage
- Enable tree shaking (Expo SDK 52+ or Re.Pack)
- Enable R8 for Android native code shrinking
Measure TTI
- Use
react-native-performancefor markers - Only measure cold starts (exclude warm/hot/prewarm)
Common fixes:
- Disable JS bundle compression on Android (enables Hermes mmap)
- Use native navigation (react-native-screens)
- Preload commonly-used expensive screens before navigating to them
Native Performance
Profile native:
- iOS: Xcode Instruments → Time Profiler
- Android: Android Studio → CPU Profiler
Common fixes:
- Use background threads for heavy native work
- Prefer async over sync Turbo Module methods
- Use C++ for cross-platform performance-critical code
Priority Guidelines
Apply optimizations in this order:
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | FPS & Re-renders | CRITICAL | js-* |
| 2 | Bundle Size | CRITICAL | bundle-* |
| 3 | TTI Optimization | HIGH | native-*, bundle-* |
| 4 | Native Performance | HIGH | native-* |
| 5 | Memory Management | MEDIUM-HIGH | js-*, native-* |
| 6 | Animations | MEDIUM | js-* |
Attribution
Based on "The Ultimate Guide to React Native Optimization" by Callstack.
Skill: Analyze App Bundle Size
Measure iOS and Android app download/install sizes using Ruler, App Store Connect, and Emerge Tools.
Quick Command
# Android (Ruler)
cd android && ./gradlew analyzeReleaseBundle
# iOS (Xcode export with thinning)
cd ios && xcodebuild -exportArchive \
-archivePath MyApp.xcarchive \
-exportPath ./export \
-exportOptionsPlist ExportOptions.plist
# Check: App Thinning Size Report.txtWhen to Use
- App download size is too large
- Users complain about storage usage
- App approaching store limits
- Comparing releases for size regression
Note: This skill involves interpreting visual size reports (Ruler, Emerge Tools X-Ray). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the reports manually, or await MCP-based visual feedback integration (see roadmap).
Key Metrics
| Metric | Description | User Impact |
|---|---|---|
| Download Size | Compressed, transferred over network | Download time, data usage |
| Install Size | Uncompressed, on device storage | Storage space |
Google finding: Every 6 MB increase reduces installs by 1%.
Android: Ruler (Spotify)
Setup
Add to android/build.gradle:
buildscript {
dependencies {
classpath("com.spotify.ruler:ruler-gradle-plugin:2.0.0-beta-3")
}
}Add to android/app/build.gradle:
apply plugin: "com.spotify.ruler"
ruler {
abi.set("arm64-v8a") // Target architecture
locale.set("en")
screenDensity.set(480)
sdkVersion.set(34)
}Analyze
cd android
./gradlew analyzeReleaseBundleOpens HTML report with:
- Download size
- Install size
- Component breakdown (biggest → smallest)
CI Size Validation
ruler {
verification {
downloadSizeThreshold = 20 * 1024 * 1024 // 20 MB
installSizeThreshold = 50 * 1024 * 1024 // 50 MB
}
}Build fails if thresholds exceeded.
iOS: Xcode App Thinning
Via App Store Connect (Most Accurate)
After uploading to TestFlight: 1. Open App Store Connect 2. Go to your build 3. View size table by device variant
Note: TestFlight builds include debug data, App Store builds slightly larger due to DRM.
Via Xcode Export
1. Archive app: Product → Archive 2. In Organizer, click Distribute App 3. Select Custom 4. Choose App Thinning: All compatible device variants
Or in ExportOptions.plist:
<key>thinning</key>
<string><thin-for-all-variants></string>Output
Creates folder with:
- Universal IPA: All variants combined
- Thinned IPAs: One per device variant
- App Thinning Size Report.txt:
Variant: SampleApp-<UUID>.ipa
App + On Demand Resources size: 3.5 MB compressed, 10.6 MB uncompressed
App size: 3.5 MB compressed, 10.6 MB uncompressed- Compressed = Download size
- Uncompressed = Install size
Emerge Tools (Cross-Platform)
Third-party service with visual analysis.
Upload
Upload IPA, APK, or AAB through their web interface or CI integration.
Features
!Emerge Tools X-Ray for iOS
- X-Ray: Treemap visualization (like source-map-explorer for binaries)
- Shows Frameworks (hermes.framework), Mach-O sections (TEXT, DATA), etc.
- Color-coded: Binaries, Localizations, Fonts, Asset Catalogs, Videos, CoreML Models
- Visible components:
main.jsbundle(JS code), RCT modules, DYLD sections - Breakdown: Component-by-component size
- Insights: Automated suggestions (use with caution)
Caution: Some suggestions may not apply to React Native (e.g., "remove Hermes").
Size Comparison
| Tool | Platform | Accuracy | CI Integration |
|---|---|---|---|
| Ruler | Android | High | Yes (Gradle) |
| App Store Connect | iOS | Highest | No |
| Xcode Export | iOS | High | Yes (xcodebuild) |
| Emerge Tools | Both | High | Yes (API) |
Typical React Native App Sizes
| Component | Approximate Size |
|---|---|
| Hermes engine | ~2-3 MB |
| React Native core | ~3-5 MB |
| JavaScript bundle | 1-10 MB |
| Assets (images, etc.) | Varies |
Baseline empty app: ~6-10 MB download
Optimization Impact Example
| Optimization | Size Reduction |
|---|---|
| Enable R8 (Android) | ~30% |
| Remove unused polyfills | 400+ KB |
| Asset catalog (iOS) | 10-50% of assets |
| Tree shaking | 10-15% |
Quick Commands
# Android release bundle size
cd android && ./gradlew bundleRelease
# Check: android/app/build/outputs/bundle/release/
# iOS archive
cd ios && xcodebuild -workspace ios/MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath MyApp.xcarchive \
archive
# Export with thinning report
cd ios && xcodebuild -exportArchive \
-archivePath MyApp.xcarchive \
-exportPath ./export \
-exportOptionsPlist ExportOptions.plistRelated Skills
- bundle-r8-android.md - Reduce Android size
- bundle-native-assets.md - Optimize asset delivery
- bundle-analyze-js.md - JS bundle analysis
Skill: Analyze JS Bundle Size
Use source-map-explorer and Expo Atlas to visualize what's in your JavaScript bundle.
Quick Command
# React Native CLI
npx react-native bundle \
--entry-file index.js \
--bundle-output output.js \
--platform ios \
--sourcemap-output output.js.map \
--dev false --minify true && \
npx source-map-explorer output.js --no-border-checks
# Expo
EXPO_UNSTABLE_ATLAS=true npx expo export --platform ios && npx expo-atlasWhen to Use
- JS bundle seems too large
- Want to identify heavy dependencies
- Investigating startup time issues
- Before/after optimization comparison
Note: This skill involves interpreting visual treemap output (source-map-explorer, Expo Atlas). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the visualization manually, or await MCP-based visual feedback integration (see roadmap).
Understanding Hermes Bytecode
Modern React Native (0.70+) uses Hermes bytecode, not raw JavaScript:
- Skips parsing at runtime
- Still benefits from smaller bundles
- Heavy imports still execute on startup
Impact of bundle size:
- Larger bytecode = longer download from store
- More imports on init path = slower TTI
Method 1: source-map-explorer
Generate Bundle with Source Map
React Native CLI:
npx react-native bundle \
--entry-file index.js \
--bundle-output output.js \
--platform ios \
--sourcemap-output output.js.map \
--dev false \
--minify trueExpo (SDK 51+):
npx expo export --platform ios --source-maps --output-dir dist
# Bundle at: dist/ios/_expo/static/js/ios/*.js
# Source map at: dist/ios/_expo/static/js/ios/*.mapAnalyze
npx source-map-explorer output.js --no-border-checksNote: --no-border-checks needed due to Metro's non-standard source maps.
Opens browser with treemap visualization:
!Bundle Treemap from source-map-explorer
The treemap shows:
- Hierarchy:
node_modules/→react-native/→Libraries/→ individual files - Size: Box area proportional to file size (KB shown in labels)
- Major components visible:
react-native(724.18 KB, 80.5%)Renderer(208.44 KB) - ReactNativeRenderer-prod.js, ReactFabric-prod.jsComponents(125.29 KB) - Touchable, ScrollView, etc.Animated(79.48 KB) - Animation systemvirtualized-lists(57.57 KB) - FlatList internals
Click on any section to drill down into that directory.
Limitation: May lose ~30% info due to mapping issues.
Method 2: Expo Atlas
More accurate for Expo projects (or with workaround for bare RN).
For Expo Projects
# Start with Atlas enabled
EXPO_UNSTABLE_ATLAS=true npx expo start --no-dev
# Or export
EXPO_UNSTABLE_ATLAS=true npx expo exportThen launch UI:
npx expo-atlas!Expo Atlas Treemap
Expo Atlas provides more accurate visualization for Expo projects, with similar treemap interface showing module sizes and dependencies.
For Non-Expo Projects
Use expo-atlas-without-expo package.
Method 3: Re.Pack Bundle Analysis (Webpack/Rspack)
If using Re.Pack:
webpack-bundle-analyzer
rspack build --analyzebundle-stats / statoscope
# Generate stats
npx react-native bundle \
--platform android \
--entry-file index.js \
--dev false \
--minify true \
--json stats.json
# Analyze
npx bundle-stats --html --json stats.jsonRsdoctor
// rspack.config.js
const { RsdoctorRspackPlugin } = require('@rsdoctor/rspack-plugin');
module.exports = {
plugins: [
process.env.RSDOCTOR && new RsdoctorRspackPlugin(),
].filter(Boolean),
};Run with:
RSDOCTOR=true npx react-native startWhat to Look For
Red Flags
| Finding | Problem | Solution |
|---|---|---|
| Entire library imported | Barrel exports | Use direct imports |
| Duplicate packages | Multiple versions | Dedupe in package.json |
| Dev dependencies in bundle | Incorrect imports | Check conditional imports |
| Large polyfills | Unnecessary for Hermes | Remove (see native-sdks-over-polyfills.md) |
| Moment.js with locales | Bloated date library | Switch to date-fns or dayjs |
Common Offenders
- Lodash full import: Use
lodash-esor specific imports - Moment.js: Replace with
date-fnsordayjs - Intl polyfills: Check Hermes API and method coverage before removing them
- AWS SDK: Import specific services only
Code Examples
Identify Barrel Import Impact
// BAD: Imports entire library through barrel
import { format } from 'date-fns';
// In bundle: All of date-fns loaded
// GOOD: Direct import
import format from 'date-fns/format';
// In bundle: Only format functionComparing Bundles
source-map-explorer
# Generate baseline
npx react-native bundle ... --bundle-output baseline.js --sourcemap-output baseline.js.map
# Make changes, generate new bundle
npx react-native bundle ... --bundle-output current.js --sourcemap-output current.js.map
# Compare manually in browserRe.Pack (automated)
npx bundle-stats compare baseline-stats.json current-stats.jsonQuick Commands
React Native CLI:
# iOS bundle analysis
npx react-native bundle \
--entry-file index.js \
--bundle-output ios-bundle.js \
--platform ios \
--sourcemap-output ios-bundle.js.map \
--dev false \
--minify true && \
npx source-map-explorer ios-bundle.js --no-border-checks
# Android bundle analysis
npx react-native bundle \
--entry-file index.js \
--bundle-output android-bundle.js \
--platform android \
--sourcemap-output android-bundle.js.map \
--dev false \
--minify true && \
npx source-map-explorer android-bundle.js --no-border-checksExpo:
# Use Expo Atlas (recommended for Expo projects)
EXPO_UNSTABLE_ATLAS=true npx expo export --platform ios
npx expo-atlasRelated Skills
- bundle-barrel-exports.md - Fix barrel import issues
- bundle-tree-shaking.md - Enable dead code elimination
- bundle-library-size.md - Check library sizes before adding
Skill: Avoid Barrel Exports
Refactor barrel imports (index files) to reduce bundle size and improve startup time.
Quick Pattern
Incorrect:
import { Button } from './components';
// Loads ALL exports from components/index.tsCorrect:
import Button from './components/Button';
// Loads only ButtonWhen to Use
- Bundle contains unused code from libraries
- Circular dependency warnings in Metro
- Hot Module Replacement (HMR) breaks frequently
- TTI is slow due to module evaluation
What Are Barrel Exports?
// components/index.ts (barrel file)
export { Button } from './Button';
export { Card } from './Card';
export { Modal } from './Modal';
export { Sidebar } from './Sidebar';
// Usage (barrel import)
import { Button } from './components';Problems with Barrel Imports
1. Bundle Size Overhead
Metro includes all exports even if you use one:
// Only need Button, but entire barrel is bundled
import { Button } from './components';
// Card, Modal, Sidebar also included!2. Runtime Overhead
All modules evaluate before returning your import:
import { Button } from './components';
// JavaScript must evaluate:
// - Button.tsx
// - Card.tsx
// - Modal.tsx
// - Sidebar.tsx
// Even though you only use Button3. Circular Dependencies
Barrel files make cycles easier to create accidentally:
Warning: Require cycle:
components/index.ts -> Button.tsx -> utils/index.ts -> components/index.tsBreaks HMR, causes unpredictable behavior.
Solution 1: Direct Imports
Replace barrel imports with direct paths:
// BEFORE: Barrel import
import { Button, Card } from './components';
// AFTER: Direct imports
import Button from './components/Button';
import Card from './components/Card';Enforce with ESLint
npm install -D eslint-plugin-no-barrel-files// eslint.config.js
import noBarrelFiles from 'eslint-plugin-no-barrel-files';
export default [
{
plugins: { 'no-barrel-files': noBarrelFiles },
rules: {
'no-barrel-files/no-barrel-files': 'error',
},
},
];Solution 2: Tree Shaking (Automatic)
Enable tree shaking to automatically remove unused barrel exports.
Expo SDK 52+
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
},
});
module.exports = config;# .env
EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1
EXPO_UNSTABLE_TREE_SHAKING=1metro-serializer-esbuild
npm install @rnx-kit/metro-serializer-esbuildRe.Pack (Webpack/Rspack)
Tree shaking built-in.
Real-World Example: date-fns
// BAD: Imports entire library
import { format, addDays, isToday } from 'date-fns';
// GOOD: Direct imports
import format from 'date-fns/format';
import addDays from 'date-fns/addDays';
import isToday from 'date-fns/isToday';Library-Specific Solutions
Some libraries provide Babel plugins:
React Native Paper
// babel.config.js
module.exports = {
plugins: [
'react-native-paper/babel', // Auto-transforms imports
],
};Transforms:
import { Button } from 'react-native-paper';
// Into:
import Button from 'react-native-paper/lib/module/components/Button';Refactoring Strategy
Step 1: Identify Barrel Files
Look for index.ts files with multiple exports:
grep -r "export \* from" src/
grep -r "export { .* } from" src/Step 2: Update Imports
// Find all usages
// VS Code: Cmd+Shift+F for "from './components'"
// Replace each with direct import
import Button from './components/Button';Step 3: (Optional) Keep Barrel for External API
If your package is consumed by others:
// Keep index.ts for package API
// components/index.ts
export { Button } from './Button';
// Internal code uses direct imports
// src/screens/Home.tsx
import Button from '../components/Button';Migration Script Example
# Use codemod or search-replace
# Find: import { (\w+) } from '\.\/components';
# Replace: import $1 from './components/$1';Verification
After refactoring:
1. Run bundle analysis (see bundle-analyze-js.md) 2. Compare sizes before/after 3. Check for circular dependency warnings
Common Pitfalls
- Breaking external consumers: If publishing a library, keep barrel for public API
- IDE auto-imports: Configure IDE to prefer direct imports
- Inconsistent patterns: Enforce with ESLint across team
Related Skills
- bundle-analyze-js.md - Verify impact
- bundle-tree-shaking.md - Automatic solution
- bundle-library-size.md - Check library patterns
Skill: Remote Code Loading
Set up code splitting with Re.Pack for on-demand bundle loading from trusted, first-party assets.
Quick Pattern
Before (static import):
import SettingsScreen from './screens/SettingsScreen';After (lazy loaded chunk):
const SettingsScreen = React.lazy(() =>
import(/* webpackChunkName: "settings" */ './screens/SettingsScreen')
);
<Suspense fallback={<Loading />}>
<SettingsScreen />
</Suspense>When to Use
Consider code splitting when:
- Not using Hermes (JSC/V8 benefits more)
- App size exceeds 200 MB (Play Store limit)
- Building micro-frontend architecture
- Loading features based on user permissions
- Other optimizations exhausted
Note: Hermes already uses memory mapping for efficient bundle reading. Benefits of code splitting are minimal with Hermes or even counterproductive in some cases.
Security Model
Remote chunks are executable application code. Only load chunks that you build and publish yourself.
Keep these guardrails in place:
- Serve chunks only from a first-party, HTTPS-only origin you control
- Resolve
scriptIdthrough a fixed allowlist or release manifest - Fail closed if a chunk is missing or unexpected
- Do not load chunks from user-controlled input, query params, or third-party domains
Prerequisites
- Re.Pack installed (replaces Metro)
npx @callstack/repack-initStep-by-Step Instructions
1. Initialize Re.Pack
npx @callstack/repack-initFollow prompts to migrate from Metro. Check migration guide.
2. Create Split Point with React.lazy
// BEFORE: Static import
import SettingsScreen from './screens/SettingsScreen';
// AFTER: Dynamic import (creates split point)
const SettingsScreen = React.lazy(() =>
import(/* webpackChunkName: "settings" */ './screens/SettingsScreen')
);3. Wrap with Suspense
import React, { Suspense } from 'react';
const App = () => {
return (
<Suspense fallback={<LoadingSpinner />}>
<SettingsScreen />
</Suspense>
);
};4. Configure Chunk Loading
// index.js (before AppRegistry)
import { ScriptManager, Script } from '@callstack/repack/client';
const CHUNK_URLS = {
settings: 'https://assets.example.com/app/v42/settings.chunk.bundle',
};
ScriptManager.shared.addResolver((scriptId) => ({
url: __DEV__ ? Script.getDevServerURL(scriptId) : getChunkUrl(scriptId),
}));
function getChunkUrl(scriptId) {
const url = CHUNK_URLS[scriptId];
if (!url) {
throw new Error(`Unknown chunk: ${scriptId}`);
}
return url;
}
AppRegistry.registerComponent(appName, () => App);5. Build and Deploy Chunks
Build generates:
index.bundle- Main bundlesettings.chunk.bundle- Lazy-loaded chunk
Deploy chunks to a first-party CDN with versioned paths, and keep the allowlist or manifest in sync with the app release.
Complete Example
// App.tsx
import React, { Suspense, useState } from 'react';
import { Button, View, ActivityIndicator } from 'react-native';
// Lazy load heavy feature
const HeavyFeature = React.lazy(() =>
import(/* webpackChunkName: "heavy-feature" */ './HeavyFeature')
);
const App = () => {
const [showFeature, setShowFeature] = useState(false);
return (
<View>
<Button
title="Load Feature"
onPress={() => setShowFeature(true)}
/>
{showFeature && (
<Suspense fallback={<ActivityIndicator />}>
<HeavyFeature />
</Suspense>
)}
</View>
);
};Module Federation (Advanced)
For micro-frontend architecture:
// Host app loads remote module
const RemoteModule = React.lazy(() =>
import('remote-app/Module')
);Enables:
- Independent team deployments
- Shared dependencies
- Runtime composition
Complexity warning: Only use when organizational benefits outweigh overhead. Federation increases the trust boundary, so keep the same first-party origin and allowlist rules as above.
Version Management
Consider Zephyr Cloud for:
- Sub-second deployments
- Version management
- Re.Pack integration
Caching Strategy
ScriptManager.shared.addResolver((scriptId) => ({
url: getChunkUrl(scriptId),
cache: {
// Enable caching
enabled: true,
// Cache location
path: `${FileSystem.cacheDirectory}/chunks/`,
},
}));When NOT to Use
| Scenario | Why Not |
|---|---|
| Using Hermes | mmap already efficient |
| Small app | Overhead not worth it |
| Simple navigation | Native navigation better |
| Quick iteration needed | Added complexity |
Hermes Memory Mapping
Hermes reads bytecode lazily via mmap:
- Only loads executed code into memory
- No parse step needed
- Code splitting provides marginal benefit
Verification
// Check if chunk loaded correctly
ScriptManager.shared.on('loading', (scriptId) => {
console.log(`Loading: ${scriptId}`);
});
ScriptManager.shared.on('loaded', (scriptId) => {
console.log(`Loaded: ${scriptId}`);
});
ScriptManager.shared.on('error', (scriptId, error) => {
console.error(`Failed: ${scriptId}`, error);
});Common Pitfalls
- Forgetting Suspense: Lazy components need fallback
- Wrong CDN path: Chunks 404 in production
- No caching: Re-downloads on every load
- Too many chunks: Network overhead exceeds savings
- Untrusted chunk source: Remote JS from third-party or user-controlled origins is equivalent to remote code execution
Related Skills
- bundle-tree-shaking.md - Re.Pack tree shaking
- bundle-analyze-js.md - Measure chunk sizes
- native-measure-tti.md - Verify TTI impact
Skill: Disable JS Bundle Compression
Disable Android JS bundle compression to enable Hermes memory mapping for faster startup.
Quick Config
// android/app/build.gradle
android {
androidResources {
noCompress += ["bundle"]
}
}Note: Default in React Native 0.79+. Only needed for 0.78 and earlier.
When to Use
- Android app using Hermes
- Want faster TTI (Time to Interactive)
- Willing to trade install size for startup speed
- React Native version is 0.78 or earlier, skip otherwise (see applicability)
Background
Android compresses most files in APK/AAB by default, including index.android.bundle.
Problem: Compressed files can't be memory-mapped (mmap).
Impact: Hermes must decompress before reading, losing one of its key optimizations.
How Hermes Memory Mapping Works
Without compression: 1. Hermes opens bytecode file 2. OS memory-maps directly to disk 3. Only pages actually accessed are loaded 4. Result: Fast startup, low memory
With compression: 1. Android decompresses entire bundle 2. Loaded into memory 3. Then Hermes processes 4. Result: Slower startup, higher memory
Step-by-Step Implementation
Edit build.gradle
In android/app/build.gradle:
android {
androidResources {
noCompress += ["bundle"]
}
}Full Context
android {
namespace "com.myapp"
defaultConfig {
applicationId "com.myapp"
// ...
}
androidResources {
noCompress += ["bundle"]
}
buildTypes {
release {
minifyEnabled true
// ...
}
}
}Rebuild
cd android
./gradlew clean
./gradlew bundleRelease
# or
./gradlew assembleReleaseTrade-offs
| Metric | Without Change | With Change |
|---|---|---|
| Download size | Same | Same |
| Install size | Smaller | +8% larger |
| TTI | Slower | -16% faster |
Real example: 75.9 MB install → 82 MB install, but 450ms faster startup.
Applicability
React Native 0.78 and earlier: Apply this optimization manually.
React Native 0.79+: Skip this—bundle compression is disabled by default.
Verification
Check APK Contents
# Unzip APK
unzip app-release.apk -d apk-contents
# Check if bundle is compressed
file apk-contents/assets/index.android.bundle
# Should show: "data" (not "gzip compressed")Measure TTI Impact
Use performance markers (see native-measure-tti.md) to compare before/after.
Multiple File Types
If you have other files that benefit from mmap:
androidResources {
noCompress += ["bundle", "hbc", "data"]
}Common Pitfalls
- Not rebuilding: Change requires clean build
- Wrong config location: Must be in
androidblock - Ignoring size increase: Monitor user feedback on install size
- Already default: Check if React Native version includes this
Expo Notes
For Expo projects, run npx expo prebuild first to generate android/ folder, then apply the build.gradle changes. Add android/ to version control or use a config plugin for persistent changes.
Should You Enable This?
| Scenario | Recommendation |
|---|---|
| Startup-critical app | ✅ Enable |
| Storage-sensitive users | ⚠️ Test impact |
| Already fast TTI | Maybe not worth it |
| Large JS bundle | ✅ Bigger benefit |
Related Skills
- native-measure-tti.md - Measure TTI improvement
- bundle-analyze-app.md - Check size impact
- bundle-r8-android.md - Offset size increase
Skill: Determine Library Size
Evaluate third-party library size impact before adding to your project.
Quick Command
# Check size before installing
# Visit: https://bundlephobia.com/package/[package-name]
# Or use CLI
npx bundle-phobia-cli <package-name>When to Use
- Evaluating new dependencies
- Comparing alternative libraries
- Auditing existing dependencies
- Investigating bundle bloat
Tools Overview
| Tool | Type | Best For |
|---|---|---|
| bundlephobia.com | Web | Quick size check |
| pkg-size.dev | Web | Backup/alternative |
| Import Cost (VS Code) | IDE extension | Real-time feedback |
bundlephobia.com
Usage
Visit bundlephobia.com and enter package name.
Shows
- Minified size: Raw JS size
- Minified + Gzipped: Network transfer size
- Download time: Estimated on various connections
- Dependencies: What else gets pulled in
- Composition: Breakdown by dependency
Example Analysis
react-native-paper
├── Minified: 312 kB
├── Gzipped: 78 kB
└── Dependencies: 12 packages
├── @callstack/react-theme-provider
├── color
└── ...pkg-size.dev
Backup when bundlephobia fails.
Visit pkg-size.dev with package name.
Difference: Actually installs package in web container, may be more accurate for edge cases.
Import Cost (VS Code Extension)
Install
Search "Import Cost" in VS Code extensions or:
code --install-extension wix.vscode-import-costUsage
Shows inline size next to imports:
import React from 'react'; // 6.5K (gzipped)
import { View, Text } from 'react-native'; // 0B (native)
import lodash from 'lodash'; // 71.5K (gzipped: 24.7K)
import get from 'lodash/get'; // 8K (gzipped: 2.9K)Limitations
- Uses Webpack internally (not Metro)
- May fail on React Native-specific packages
- Doesn't account for tree shaking
Comparison Workflow
Before Adding Dependency
1. Check on bundlephobia:
https://bundlephobia.com/package/[package-name]2. Compare alternatives:
moment (289 kB) vs date-fns (75 kB) vs dayjs (6 kB)3. Check what you actually need:
- Full library import vs specific functions
- Native alternative available?
After Adding
1. Analyze bundle (see bundle-analyze-js.md) 2. Verify actual impact matches expected 3. Check for duplicate dependencies
Size Guidelines
| Size (gzipped) | Assessment | Action |
|---|---|---|
| < 5 KB | Small | Generally fine |
| 5-20 KB | Medium | Evaluate necessity |
| 20-50 KB | Large | Look for alternatives |
| > 50 KB | Very large | Strong justification needed |
Common Large Dependencies
| Library | Size (gzipped) | Alternative |
|---|---|---|
| moment | ~70 KB | dayjs (~3 KB) |
| lodash (full) | ~25 KB | lodash-es + direct imports |
| aws-sdk (full) | 200+ KB | @aws-sdk/client-* |
| crypto-js | ~15 KB | react-native-quick-crypto |
Quick Size Check Script
# Check size before installing
npx bundle-phobia-cli <package-name>
# Or use npm directly (less accurate)
npm pack <package-name> --dry-run 2>&1 | grep "total files"Decision Matrix
| Factor | Keep JS Library | Use Native Alternative |
|---|---|---|
| Size | > 50 KB | < 50 KB |
| Platform coverage | Both platforms | Single platform OK |
| Performance | Not critical | Critical path |
| Functionality | Simple | Complex computation |
Code Example: Optimizing Imports
// BAD: Full library (71.5 KB)
import _ from 'lodash';
_.get(obj, 'path.to.value');
// BETTER: Specific import (8 KB)
import get from 'lodash/get';
get(obj, 'path.to.value');
// BEST: Native JS (0 KB)
obj?.path?.to?.value;Related Skills
- bundle-analyze-js.md - Verify actual bundle impact
- bundle-barrel-exports.md - Optimize how you import
- native-sdks-over-polyfills.md - Native alternatives to JS libs
Skill: Native Assets
Configure platform-specific asset delivery to reduce app download size.
Quick Config
iOS Asset Catalog (Build Phase):
# Add to "Bundle React Native code and images" build phase
export EXTRA_PACKAGER_ARGS="--asset-catalog-dest ./"Android: Automatic via AAB — Play Store delivers correct density per device.
When to Use
- Images bloating app size
- Different device densities need different assets
- Want to leverage App Store/Play Store optimization
- Using high-resolution images
Concept: Size Suffixes
React Native convention for multiple resolutions:
assets/
├── image.jpg # 1x resolution (base)
├── image@2x.jpg # 2x resolution
└── image@3x.jpg # 3x resolution// React Native selects best one for device
<Image source={require('./assets/image.jpg')} />Android: Automatic Optimization
Android handles this automatically.
How It Works
1. Build AAB:
cd android && ./gradlew bundleRelease2. Metro places images in density folders:
android/app/build/outputs/bundle/release/
└── base/
└── res/
├── drawable-mdpi-v4/ # 1x
├── drawable-hdpi-v4/ # 1.5x
├── drawable-xhdpi-v4/ # 2x
├── drawable-xxhdpi-v4/ # 3x
└── drawable-xxxhdpi-v4/ # 4x3. Play Store delivers only needed density per device.
No configuration required for Android.
iOS: Asset Catalog Setup
iOS requires explicit configuration.
Step 1: Create Asset Catalog
Create folder in ios/:
ios/RNAssets.xcassets/Important: Must be named exactly RNAssets.xcassets (hardcoded in React Native).
Step 2: Configure Build Phase
In Xcode: 1. Open project settings 2. Go to Build Phases 3. Find "Bundle React Native code and images" 4. Add before line 8:
export EXTRA_PACKAGER_ARGS="--asset-catalog-dest ./"Step 3: Build
Run build to populate asset catalog:
npx react-native run-ios --mode ReleaseOr manually:
npx react-native bundle \
--entry-file index.js \
--bundle-output ios-bundle.js \
--platform ios \
--dev false \
--asset-catalog-dest ios \
--assets-dest ios/assetsStep 4: Verify
After build, RNAssets.xcassets contains:
ios/RNAssets.xcassets/
└── assets_image_image.imageset/
├── Contents.json
├── image.jpg
├── image@2x.jpg
└── image@3x.jpgApp Store then delivers only needed resolution.
Before/After Comparison
Without Asset Catalog (All Variants)
App bundle contains:
├── image.jpg (100 KB)
├── image@2x.jpg (300 KB)
└── image@3x.jpg (600 KB)
Total: 1 MBWith Asset Catalog (Device-Specific)
iPhone 15 Pro receives:
└── image@3x.jpg (600 KB)
Total: 600 KB (40% smaller)Asset Optimization Tips
1. Compress Images
Use tools before adding to project:
# ImageOptim (macOS)
# TinyPNG (web)
# sharp (programmatic)
npx sharp-cli input.jpg -o output.jpg --quality 802. Use Appropriate Formats
| Format | Best For |
|---|---|
| JPEG | Photos |
| PNG | Icons, transparency |
| WebP | Both (smaller) |
| SVG | Vector icons |
3. Consider react-native-fast-image
Caching and better image handling:
npm install react-native-fast-imageVerification
iOS App Thinning Report
After export, check App Thinning Size Report.txt:
Variant: MyApp-<UUID>.ipa
Supported variant descriptors: iPhone15,2 ...
App size: 3.5 MB compressed, 10.6 MB uncompressedUse Emerge Tools
Upload IPA to see asset breakdown.
Common Pitfalls
- Wrong folder name: Must be
RNAssets.xcassetsexactly - Missing build phase config: Assets not processed
- Not using size suffixes: All variants included anyway
- Forgetting to rebuild: Changes need fresh build
Future Note
As of January 2025, Asset Catalog is not default. May become default in future React Native versions.
Related Skills
- bundle-analyze-app.md - Verify asset impact
- bundle-r8-android.md - Android code optimization
Skill: R8 Code Shrinking
Enable R8 for Android to shrink, optimize, and obfuscate native code.
Quick Config
// android/app/build.gradle
def enableProguardInReleaseBuilds = true
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
}
}
}When to Use
- Android app size too large
- Want to obfuscate code for security
- Building release APK/AAB
What is R8?
R8 replaces ProGuard in Android:
- Shrinks: Removes unused code
- Optimizes: Improves bytecode
- Obfuscates: Renames classes/methods
Compatibility: Uses ProGuard configuration format.
Step-by-Step Instructions
1. Enable R8
Edit android/app/build.gradle:
def enableProguardInReleaseBuilds = trueThis sets minifyEnabled = true for release builds.
2. Enable Resource Shrinking (Optional)
Further reduces size by removing unused resources:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true // Requires minifyEnabled
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}3. Configure ProGuard Rules (If Needed)
Edit android/app/proguard-rules.pro. React Native defaults are usually sufficient—only add rules when specific libraries break after enabling R8.
*Only add if using Firebase (`@react-native-firebase/`):**
-keep class io.invertase.firebase.** { *; }
-dontwarn io.invertase.firebase.**Only add if using Retrofit:
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-dontwarn retrofit2.**See Common Library Rules and Troubleshooting for more examples.
4. Build and Test
cd android
./gradlew assembleRelease
# or
./gradlew bundleReleaseCritical: Test thoroughly! R8 can remove code it thinks is unused.
ProGuard Rules Reference
| Rule | Effect |
|---|---|
-keep class X | Don't remove class X |
-keepclassmembers | Keep members but allow rename |
-keepnames | Keep names but allow removal if unused |
-dontwarn X | Suppress warnings for X |
-dontobfuscate | Disable obfuscation |
Keep Entire Package
-keep class com.mypackage.** { *; }Keep Classes with Annotation
-keep @interface com.facebook.proguard.annotations.DoNotStrip
-keep @com.facebook.proguard.annotations.DoNotStrip class *
-keepclassmembers class * {
@com.facebook.proguard.annotations.DoNotStrip *;
}Disable Obfuscation (If Needed)
# proguard-rules.pro
-dontobfuscateUse when:
- Debugging crashes (stack traces more readable)
- Library requires class names
Size Impact
Example from guide:
- Without R8: 9.5 MB
- With R8: 6.3 MB
- Savings: 33%
Larger apps may see 20-30% reduction.
Troubleshooting
App Crashes After R8
Usually means needed class was removed.
Debug steps:
1. Check crash log for class name 2. Add keep rule:
-keep class com.example.CrashedClass { *; }3. Rebuild and test
Library Specific Rules
Many libraries provide ProGuard rules. Check:
- Library README
- Library's
consumer-proguard-rules.pro - Stack Overflow for library + proguard
Common Library Rules
# Hermes (usually auto-included)
-keep class com.facebook.hermes.unicode.** { *; }
# React Native
-keep class com.facebook.react.** { *; }
# Gson
-keepattributes Signature
-keep class com.google.gson.** { *; }
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**Verification
Check APK Size
# Build
./gradlew assembleRelease
# Check size
ls -la android/app/build/outputs/apk/release/Use Ruler for Detailed Analysis
See bundle-analyze-app.md.
Verify Obfuscation
Decompile APK to check class names are obfuscated:
# Using jadx or similar
jadx android/app/build/outputs/apk/release/app-release.apkCommon Pitfalls
- Not testing release build: Always QA with R8 enabled
- Missing library rules: Check library docs
- Over-keeping: Too many keep rules negates benefits
- Reflection: Code using reflection may break
Related Skills
- bundle-analyze-app.md - Measure size impact
- bundle-native-assets.md - Further size reduction
Skill: Tree Shaking
Enable dead code elimination to remove unused exports from your JavaScript bundle.
Quick Config
# .env (Expo SDK 52+)
EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1
EXPO_UNSTABLE_TREE_SHAKING=1// metro.config.js
config.transformer.getTransformOptions = async () => ({
transform: { experimentalImportSupport: true },
});// babel.config.js (non-Expo projects must set `disableImportExportTransform`)
module.exports = {
presets: [
[
'module:@react-native/babel-preset',
{ disableImportExportTransform: true },
],
],
};When to Use
- Bundle includes unused library code
- Want automatic barrel export optimization
- Using Expo SDK 52+ (recommended approach)
- Project already uses Re.Pack (
@callstack/repack)
Platform Support
| Bundler | Tree Shaking | Notes |
|---|---|---|
| Metro | ❌ No | Use metro-serializer-esbuild |
| Expo (SDK 52+) | ✅ Experimental | Requires config |
| Re.Pack | ✅ Yes | Built-in via Webpack/Rspack |
Setup: Expo SDK 52+
1. Enable Import Support
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
},
});
module.exports = config;2. Enable Tree Shaking
Create/edit .env:
EXPO_UNSTABLE_METRO_OPTIMIZE_GRAPH=1
EXPO_UNSTABLE_TREE_SHAKING=1Note: Only applies in production builds.
Setup: Metro with ESBuild
npm install @rnx-kit/metro-serializer-esbuildConfigure in metro.config.js.
Setup: Re.Pack (Only If Already Installed)
Important: Re.Pack (@callstack/repack) is a custom bundler using Rspack/Webpack. Only apply these instructions if the project already has Re.Pack configured. Do not suggest migrating a codebase to Re.Pack—it's rarely necessary and requires significant setup.If project has `@callstack/repack` in dependencies:
Tree shaking is enabled by default with Rspack. Verify in config:
// rspack.config.js or webpack.config.js
module.exports = {
optimization: {
usedExports: true, // Mark unused exports
minimize: true, // Remove during minification
},
};Platform Shaking
Code inside Platform.OS and Platform.select checks is removed for other platforms:
// IMPORTANT: import Platform directly from 'react-native'
import { Platform } from 'react-native';
if (Platform.OS === 'ios') {
// Removed from Android bundle
}
if (Platform.select({ ios: true, android: false }) === 'ios') {
// Removed from Android bundle
}Critical: Must use direct import. This does NOT work:
import * as RN from 'react-native';
if (RN.Platform.OS === 'ios') {
// NOT removed - optimization fails
}For non-Expo projects, requires both experimentalImportSupport: true in Metro config and disableImportExportTransform: true in Babel config.
Impact: Savings from enabling platform shaking on a bare React Native Community CLI project are:
- 5% smaller Hermes bytecode (2.79 MB → 2.64 MB)
- 15% smaller minified JS bundle (1 MB → 0.85 MB)
Requirements for Tree Shaking
ESM Imports Required
// ✅ ESM - Tree shakeable
import { foo } from './module';
// ❌ CommonJS - Not tree shakeable
const { foo } = require('./module');Side Effects Declaration
Libraries must declare side-effect-free in package.json:
{
"sideEffects": false
}Or specify files with side effects:
{
"sideEffects": ["*.css", "./src/polyfills.js"]
}Size Impact
| Bundle Type | Metro (MB) | Re.Pack (MB) | Change |
|---|---|---|---|
| Production | 35.63 | 38.48 | +8% |
| Prod Minified | 15.54 | 13.36 | -14% |
| Prod HBC | 21.79 | 19.35 | -11% |
| Prod Minified HBC | 21.62 | 19.05 | -12% |
Expected improvement: 10-15% bundle size reduction.
Verification
1. Build production bundle (see bundle-analyze-js.md) 2. Analyze with source-map-explorer (see bundle-analyze-js.md) 3. Search for functions you know are unused 4. If found → tree shaking not working
Test Example
// test-treeshake.js
export const usedFunction = () => 'used';
export const unusedFunction = () => 'unused'; // Should be removed
// app.js
import { usedFunction } from './test-treeshake';After building, search bundle for unusedFunction. Should not exist.
Common Pitfalls
- Not using production build: Tree shaking only in prod
- CommonJS modules: Need ESM for full effectiveness
- Side effects not declared: Library may not be shakeable
- Dynamic imports:
require(variable)prevents analysis - Babel/Metro config mismatch:
disableImportExportTransformmust matchexperimentalImportSupport
Related Skills
- bundle-analyze-js.md - Verify tree shaking effect
- bundle-barrel-exports.md - Manual alternative
- bundle-code-splitting.md - Re.Pack code splitting
Skill: High-Performance Animations
Use React Native Reanimated for smooth 60+ FPS animations.
Quick Pattern
Incorrect (JS thread - blocks on heavy work):
const opacity = useRef(new Animated.Value(0)).current;
Animated.timing(opacity, { toValue: 1 }).start();Correct (UI thread - smooth even during JS work):
const opacity = useSharedValue(0);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
opacity.value = withTiming(1);When to Use
- Animations drop frames or feel janky
- UI freezes during animations
- Need gesture-driven animations
- Want animations to run during heavy JS work
Prerequisites
react-native-reanimated(v4+) andreact-native-workletsinstalled
npm install react-native-reanimated react-native-workletsAdd to babel.config.js:
module.exports = {
plugins: ['react-native-worklets/plugin'], // Must be last
};Note: Reanimated 4 requires React Native's New Architecture (Fabric + TurboModules). The Legacy Architecture is no longer supported. If upgrading from v3, see the migration notes at the end of this document.
Key Concepts
Main Thread vs JS Thread
- Main/UI Thread: Handles native rendering (60+ FPS target)
- JS Thread: Runs React and your JavaScript
Problem: Heavy JS work blocks animations running on JS thread.
Solution: Run animations on UI thread with Reanimated worklets.
Step-by-Step Instructions
1. Basic Animated Style (UI Thread)
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming
} from 'react-native-reanimated';
const FadeInView = () => {
const opacity = useSharedValue(0);
// This runs on UI thread - won't be blocked by JS
const animatedStyle = useAnimatedStyle(() => {
return { opacity: opacity.value };
});
useEffect(() => {
opacity.value = withTiming(1, { duration: 500 });
}, []);
return <Animated.View style={[styles.box, animatedStyle]} />;
};2. Run Code on UI Thread with scheduleOnUI
import { scheduleOnUI } from 'react-native-worklets';
const triggerAnimation = () => {
scheduleOnUI(() => {
'worklet';
console.log('Running on UI thread');
// Direct UI manipulations here
});
};3. Call JS from UI Thread with scheduleOnRN
import { scheduleOnRN } from 'react-native-worklets';
// Regular JS function
const trackAnalytics = (value) => {
analytics.track('animation_complete', { value });
};
const AnimatedComponent = () => {
const progress = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => {
// When animation completes, call JS function
if (progress.value === 1) {
scheduleOnRN(trackAnalytics, progress.value);
}
return { opacity: progress.value };
});
return <Animated.View style={animatedStyle} />;
};4. Animation with Callback
import { scheduleOnRN } from 'react-native-worklets';
const AnimatedButton = () => {
const scale = useSharedValue(1);
const onComplete = () => {
console.log('Animation finished!');
};
const handlePress = () => {
scale.value = withTiming(
1.2,
{ duration: 200 },
(finished) => {
if (finished) {
scheduleOnRN(onComplete);
}
}
);
};
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Pressable onPress={handlePress}>
<Animated.View style={[styles.button, animatedStyle]}>
<Text>Press Me</Text>
</Animated.View>
</Pressable>
);
};When to Use What
| Thread | Best For |
|---|---|
| UI Thread (worklets) | Visual animations, transforms, gestures |
| JS Thread | State updates, data processing, API calls |
| Hook/API | Use Case |
|---|---|
useAnimatedStyle | Animated styles (auto UI thread) |
scheduleOnUI | Manual UI thread execution (from react-native-worklets) |
scheduleOnRN | Call JS functions from worklets (from react-native-worklets) |
useTransition | Alternative for React state-driven delays |
Common Pitfalls
- Accessing React state in worklets: Use
useSharedValueinstead ofuseStatefor animated values - Not using Animated components: Must use
Animated.View,Animated.Text, etc. - Heavy computation in useAnimatedStyle: Keep worklets fast
- Forgetting 'worklet' directive: Required for inline worklet functions
// BAD: Regular function in useAnimatedStyle
const style = useAnimatedStyle(() => {
heavyComputation(); // Blocks UI thread!
return { opacity: 1 };
});
// GOOD: Keep worklets fast
const style = useAnimatedStyle(() => {
return { opacity: opacity.value }; // Just read value
});Migrating from Reanimated 3.x to 4.x
If you're upgrading from Reanimated 3.x, here are the key changes.
Can't upgrade to v4? If your project is blocked from migrating to New Architecture (e.g., incompatible native libraries, complex native code, or timeline constraints), keep using existing APIs and leverage native drivers where applicable. Avoid introducing legacy Reanimated 3.x or older to reduce future migration complexity.
Breaking Changes
| Old API (v3) | New API (v4) | Package |
|---|---|---|
runOnUI(() => {...})() | scheduleOnUI(() => {...}) | react-native-worklets |
runOnJS(fn)(args) | scheduleOnRN(fn, args) | react-native-worklets |
executeOnUIRuntimeSync | runOnUISync | react-native-worklets |
runOnRuntime | scheduleOnRuntime | react-native-worklets |
useScrollViewOffset | useScrollOffset | react-native-reanimated |
useWorkletCallback | Use useCallback with 'worklet'; directive | React |
Removed APIs
useAnimatedGestureHandler- Migrate to the Gesture API fromreact-native-gesture-handlerv2+addWhitelistedNativeProps/addWhitelistedUIProps- No longer neededcombineTransition- UseEntryExitTransition.entering(...).exiting(...)instead
withSpring Changes
// Before (v3)
withSpring(value, {
restDisplacementThreshold: 0.01,
restSpeedThreshold: 0.01,
duration: 300,
});
// After (v4)
withSpring(value, {
energyThreshold: 0.01, // Replaces both threshold parameters
duration: 200, // Duration is now "perceptual" (~1.5x actual time)
});Migration Checklist
1. Enable New Architecture - Reanimated 4 only supports Fabric + TurboModules 2. Install `react-native-worklets` - Required new dependency 3. Update Babel plugin - Change 'react-native-reanimated/plugin' to 'react-native-worklets/plugin' 4. Update imports - Move worklet functions to react-native-worklets 5. Update API calls - New functions take callback + args directly (not curried) 6. Rebuild native apps - Required after adding react-native-worklets
Related Skills
- js-measure-fps.md - Verify animation frame rate
- js-bottomsheet.md - Keep bottom sheet visual state on the UI thread
- js-concurrent-react.md - React-level deferral with useTransition
Skill: Atomic State Management
Use atomic state libraries (Jotai, Zustand) to reduce unnecessary re-renders without manual memoization.
Quick Pattern
Before (Context - all consumers re-render):
const { filter, todos } = useContext(TodoContext);
// Re-renders when ANY state changesAfter (Zustand - only subscribed state):
const filter = useTodoStore((s) => s.filter);
// Only re-renders when filter changesWhen to Use
- Global state changes cause widespread re-renders
- Using React Context for app state
- Components re-render even when their data hasn't changed
- Want to avoid manual
useMemo/useCallbackeverywhere - Not ready to adopt React Compiler
Prerequisites
- State management library:
jotaiorzustand
npm install jotai
# or
npm install zustandProblem Description
With traditional React state or Context:
// When filter OR todos change, EVERYTHING re-renders
const App = () => {
const [filter, setFilter] = useState('all');
const [todos, setTodos] = useState([]);
return (
<>
<FilterMenu filter={filter} setFilter={setFilter} />
<TodoList todos={todos} filter={filter} setTodos={setTodos} />
</>
);
};Changing a todo re-renders FilterMenu even though it doesn't use todos.
Step-by-Step Instructions
Using Jotai
1. Define Atoms
import { atom } from 'jotai';
// Each atom is an independent piece of state
const filterAtom = atom('all');
const todosAtom = atom([]);
// Derived atom (computed value)
const filteredTodosAtom = atom((get) => {
const filter = get(filterAtom);
const todos = get(todosAtom);
if (filter === 'active') return todos.filter(t => !t.completed);
if (filter === 'completed') return todos.filter(t => t.completed);
return todos;
});2. Use Atoms in Components
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
// Only re-renders when filterAtom changes
const FilterMenu = () => {
const [filter, setFilter] = useAtom(filterAtom);
return (
<View>
{['all', 'active', 'completed'].map((f) => (
<Pressable key={f} onPress={() => setFilter(f)}>
<Text style={filter === f ? styles.active : null}>{f}</Text>
</Pressable>
))}
</View>
);
};
// Only re-renders when todosAtom changes
const TodoItem = ({ id }) => {
const setTodos = useSetAtom(todosAtom); // Only setter, no re-render on read
const toggleTodo = () => {
setTodos((prev) =>
prev.map((t) => t.id === id ? { ...t, completed: !t.completed } : t)
);
};
return <Pressable onPress={toggleTodo}>...</Pressable>;
};Using Zustand
1. Create Store
import { create } from 'zustand';
const useTodoStore = create((set, get) => ({
filter: 'all',
todos: [],
setFilter: (filter) => set({ filter }),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, completed: !t.completed } : t
),
})),
// Selector for derived state
getFilteredTodos: () => {
const { filter, todos } = get();
if (filter === 'active') return todos.filter(t => !t.completed);
if (filter === 'completed') return todos.filter(t => t.completed);
return todos;
},
}));2. Use Selectors
// Only re-renders when filter changes
const FilterMenu = () => {
const filter = useTodoStore((state) => state.filter);
const setFilter = useTodoStore((state) => state.setFilter);
return (
<View>
{['all', 'active', 'completed'].map((f) => (
<Pressable key={f} onPress={() => setFilter(f)}>
<Text>{f}</Text>
</Pressable>
))}
</View>
);
};
// Only re-renders when todos change
const TodoList = () => {
const todos = useTodoStore((state) => state.todos);
return todos.map((todo) => <TodoItem key={todo.id} {...todo} />);
};Code Examples
Before: Context-Based (Many Re-renders)
const TodoContext = createContext();
const TodoProvider = ({ children }) => {
const [state, setState] = useState({ filter: 'all', todos: [] });
return (
<TodoContext.Provider value={{ state, setState }}>
{children}
</TodoContext.Provider>
);
};
// Every component using this context re-renders on ANY state change
const FilterMenu = () => {
const { state, setState } = useContext(TodoContext);
// Re-renders when todos change too!
};After: Atomic (Targeted Re-renders)
// Jotai version - only affected components re-render
const filterAtom = atom('all');
const todosAtom = atom([]);
const FilterMenu = () => {
const [filter, setFilter] = useAtom(filterAtom);
// Only re-renders when filter changes
};
const TodoList = () => {
const todos = useAtomValue(todosAtom);
// Only re-renders when todos change
};Comparison
| Feature | Context | Jotai | Zustand |
|---|---|---|---|
| Re-render scope | All consumers | Atom subscribers | Selector subscribers |
| Derived state | Manual | Built-in atoms | Selectors |
| DevTools | React DevTools | Jotai DevTools | Zustand DevTools |
| Bundle size | 0 KB | ~3 KB | ~2 KB |
| Learning curve | Low | Medium | Low |
When to Use Which
- Jotai: Fine-grained state, many small atoms, derived/async atoms
- Zustand: Simpler mental model, single store, familiar Redux-like pattern
- React Compiler: If available, may eliminate need for these libraries
Common Pitfalls
- Over-atomizing: Don't create an atom for every variable. Group related state.
- Missing selectors in Zustand: Always use selectors to prevent unnecessary re-renders.
- Derived state without memoization: Use derived atoms (Jotai) or memoized selectors.
Related Skills
- js-bottomsheet.md - Avoid context-driven bottom sheet subtree re-renders
- js-react-compiler.md - Automatic memoization alternative
- js-profile-react.md - Verify re-render reduction
Skill: Bottom Sheet Best Practices
Optimize @gorhom/bottom-sheet for smooth 60 FPS by keeping gesture/scroll-driven state on the UI thread.
Quick Pattern
Incorrect (can re-enter JS repeatedly during interaction — full subtree re-render):
const handleAnimate = useCallback((fromIndex, toIndex) => {
setIsExpanded(toIndex > 0); // re-renders entire tree
}, []);
<BottomSheet onAnimate={handleAnimate}>
<ExpensiveContent isExpanded={isExpanded} />
</BottomSheet>Correct (stays on UI thread — zero re-renders):
const animatedIndex = useSharedValue(0);
const overlayStyle = useAnimatedStyle(() => ({
opacity: withTiming(animatedIndex.value > 0 ? 0.5 : 0),
}));
<BottomSheet animatedIndex={animatedIndex}>
<ExpensiveContent />
</BottomSheet>
<Animated.View style={[styles.overlay, overlayStyle]} />When to Use
- Implementing or optimizing a bottom sheet with
@gorhom/bottom-sheet - Bottom sheet gestures cause jank or dropped frames
- Scroll inside bottom sheet triggers excessive re-renders
- Context provider wrapping bottom sheet re-renders the entire subtree
- Visual-only state (shadow, opacity, footer visibility) managed with
useState - Need to choose between
BottomSheetandBottomSheetModal - Scrollable content inside bottom sheet doesn't coordinate with gestures
- Keyboard doesn't interact properly with the sheet
Prerequisites
- Check the official `@gorhom/bottom-sheet` versioning / compatibility table first.
- If your app is on
@gorhom/bottom-sheetbelow v5, upgrade to v5 before applying the patterns in this skill. @gorhom/bottom-sheetv5 is the current maintained line and is built forreact-native-reanimatedv3.react-native-reanimatedv4 may work in some apps, but the bottom-sheet docs do not officially guarantee it. Decide explicitly whether to stay on v3 or try v4 and validate thoroughly on device.react-native-gesture-handlerv2+
npm install @gorhom/bottom-sheet@^5 react-native-reanimated@^3 react-native-gesture-handlerNote: In v5,enableDynamicSizingdefaults totrue. If you need fixed snap-point indexing or do not want the library to insert a dynamic snap point based on content height, setenableDynamicSizing={false}explicitly.
Problem Description
Bottom-sheet gesture, animation, and scroll callbacks that update React state can re-render the sheet subtree during interaction. In practice, callbacks like onAnimate may run repeatedly as the sheet retargets animations, which can cause visible jank if they drive expensive React updates.
Step-by-Step Instructions
1. Convert Gesture-Driven State to SharedValue
Avoid React state for gesture-driven visual state. Update a shared value and consume it via useAnimatedStyle.
Before:
const [shadowOpacity, setShadowOpacity] = useState(0);
const handleAnimate = useCallback((fromIndex, toIndex) => {
setShadowOpacity(toIndex > 0 ? 0.3 : 0);
}, []);
<BottomSheet onAnimate={handleAnimate}>
<View style={{ shadowOpacity }}>
<HeavyContent />
</View>
</BottomSheet>After:
const animatedIndex = useSharedValue(0);
const shadowStyle = useAnimatedStyle(() => ({
shadowOpacity: withTiming(animatedIndex.value > 0 ? 0.3 : 0),
}));
<BottomSheet animatedIndex={animatedIndex}>
<Animated.View style={shadowStyle}>
<HeavyContent />
</Animated.View>
</BottomSheet>2. Drive Sheet-Index Visibility via useAnimatedReaction
Toggling content based on sheet index via {showFooter && <Footer/>} causes mount/unmount cycles on every snap. Instead, always mount, animate visibility from animatedIndex, and bridge only the minimal boolean needed for pointerEvents/accessibility — scoped to a wrapper so the full tree doesn't re-render.
Before:
const [showFooter, setShowFooter] = useState(false);
// re-mounts footer on every toggle
{showFooter && <Footer />}After:
const SheetVisibilityWrapper = ({ animatedIndex, threshold = 1, children }) => {
const [isInteractive, setIsInteractive] = useState(false);
const style = useAnimatedStyle(() => ({
opacity: withTiming(animatedIndex.value >= threshold ? 1 : 0),
transform: [{ translateY: withTiming(animatedIndex.value >= threshold ? 0 : 50) }],
}));
useAnimatedReaction(
() => animatedIndex.value >= threshold,
(visible, prev) => {
if (visible !== prev) runOnJS(setIsInteractive)(visible);
}
);
return (
<Animated.View
style={style}
pointerEvents={isInteractive ? 'auto' : 'none'}
accessibilityElementsHidden={!isInteractive}
importantForAccessibility={isInteractive ? 'auto' : 'no-hide-descendants'}
>
{children}
</Animated.View>
);
};
// Usage:
<SheetVisibilityWrapper animatedIndex={animatedIndex}>
<Footer />
</SheetVisibilityWrapper>3. Keep Scroll-Driven Logic off the JS Thread
BottomSheetScrollView ignores scrollEventThrottle, so setting it is not an optimization. Keep JS onScroll work minimal, or move scroll-driven logic to useAnimatedScrollHandler (see js-animations-reanimated.md) so it stays on the UI thread:
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollY.value = event.contentOffset.y;
});
<BottomSheetScrollView onScroll={scrollHandler}>
<Content />
</BottomSheetScrollView>4. Use Library-Provided Components and Props
Scrollables — always use these instead of React Native built-ins inside a bottom sheet:
import {
BottomSheetScrollView,
BottomSheetFlatList,
BottomSheetSectionList,
} from '@gorhom/bottom-sheet';
// FlashList v2: BottomSheetFlashList is deprecated.
// Create the scroll component, then pass it to FlashList.
import { useBottomSheetScrollableCreator } from '@gorhom/bottom-sheet';
import { FlashList } from '@shopify/flash-list';
const BottomSheetFlashListScrollComponent = useBottomSheetScrollableCreator();
<BottomSheet snapPoints={snapPoints} enableDynamicSizing={false}>
<FlashList
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
renderScrollComponent={BottomSheetFlashListScrollComponent}
/>
</BottomSheet>Key props:
| Prop | Purpose |
|---|---|
containerHeight | Provide to skip extra measurement re-render on mount |
enableDynamicSizing={false} | Use when you want fixed snap-point indexing and do not want a dynamic content-height snap point inserted |
animatedIndex | SharedValue for continuous index tracking on UI thread |
animatedPosition | SharedValue for continuous position tracking on UI thread |
onChange | Fires on snap completion only (discrete) — use for analytics/side effects |
onAnimate | Fires before each animation start/retarget — use sparingly, because it can run repeatedly during interaction |
5. BottomSheetModal Setup
import {
BottomSheetModal,
BottomSheetModalProvider,
} from '@gorhom/bottom-sheet';
const App = () => (
<BottomSheetModalProvider>
<BottomSheetModal
ref={modalRef}
snapPoints={snapPoints}
enableDismissOnClose={true}
>
<Content />
</BottomSheetModal>
</BottomSheetModalProvider>
);iOS layering fix — use FullWindowOverlay to render above native navigation:
import { FullWindowOverlay } from 'react-native-screens';
<BottomSheetModal
containerComponent={(props) => <FullWindowOverlay>{props.children}</FullWindowOverlay>}
>6. Keyboard Handling
<BottomSheet
snapPoints={snapPoints}
enableDynamicSizing={false}
keyboardBehavior="interactive" // 'extend' | 'fillParent' | 'interactive'
keyboardBlurBehavior="restore" // reset sheet position when keyboard dismisses
enableBlurKeyboardOnGesture={true} // dismiss keyboard on drag
>
<BottomSheetTextInput
placeholder="Type here..."
style={styles.input}
/>
</BottomSheet>keyboardBehavior | Effect |
|---|---|
extend | Sheet grows to accommodate keyboard |
fillParent | Sheet fills parent when keyboard appears |
interactive | Sheet follows keyboard position interactively |
PreferBottomSheetTextInputinside a bottom sheet. If you need a custom input, copy the focus/blur handlers from the library'sBottomSheetTextInputimplementation so keyboard handling still works correctly.
Derived Animations with animatedPosition
Use the animatedPosition shared value for smooth derived UI that stays on the UI thread:
const animatedPosition = useSharedValue(0);
const backdropStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animatedPosition.value,
[0, 300],
[0.5, 0],
Extrapolation.CLAMP
),
}));
<BottomSheet animatedPosition={animatedPosition} snapPoints={snapPoints}>
<Content />
</BottomSheet>
<Animated.View style={[StyleSheet.absoluteFill, backdropStyle]} pointerEvents="none" />Native Alternative: react-native-true-sheet
If your app already runs on New Architecture (Fabric), consider @lodev09/react-native-true-sheet — a fully native bottom sheet that sidesteps JS re-render problems entirely.
| Scenario | Recommendation |
|---|---|
| Need deep JS customization (custom gestures, animated derived UI) | @gorhom/bottom-sheet |
| Standard sheet with native feel + accessibility | react-native-true-sheet |
| Legacy Architecture (no Fabric) | @gorhom/bottom-sheet (true-sheet v3+ requires Fabric) |
| Web support needed | Either (true-sheet uses @gorhom/bottom-sheet on web internally) |
Advantages: zero JS overhead (sheet lives in native land — no SharedValue plumbing needed), built-in keyboard handling, native screen reader support, side sheet on tablets, iOS 26+ Liquid Glass support, React Navigation sheet navigator integration.
Requirements: New Architecture (Fabric) for v3+, use v2.x for Legacy Architecture.
npm install @lodev09/react-native-true-sheetIf requirements are met and you don't need the fine-grained Reanimated-driven customization described in this skill, react-native-true-sheet is the simpler and more performant choice.Common Pitfalls
- Using `onChange` for continuous position tracking — it fires on snap completion only (discrete). Use
animatedPositionoranimatedIndexshared values instead. - Forgetting `pointerEvents='none'` on always-mounted hidden elements — invisible elements still capture touches.
- Missing accessibility attributes on hidden elements — add
accessibilityElementsHiddenandimportantForAccessibility='no-hide-descendants'. - Bundling independent state values in one context — see js-atomic-state.md for splitting patterns.
- Assuming `enableDynamicSizing` must be disabled whenever you pass `snapPoints` — it does not have to be, but leaving it enabled can insert an additional snap point and change indexing.
- Using React Native `ScrollView`/`FlatList` inside bottom sheet — gestures won't coordinate. Use
BottomSheetScrollView,BottomSheetFlatList, etc. - Using React Native touchables on Android — import
TouchableOpacity,TouchableHighlight, orTouchableWithoutFeedbackfrom@gorhom/bottom-sheet. - Not providing `containerHeight` — causes an extra re-render on mount for measurement.
- Using a custom `TextInput` without porting the library's focus/blur handlers — keyboard handling will be incomplete. Prefer
BottomSheetTextInputunless you need a custom input.
Related Skills
- js-animations-reanimated.md — SharedValue and useAnimatedStyle fundamentals
- js-atomic-state.md — Context splitting and atomic state patterns
- js-profile-react.md — Profiling to measure re-render reduction
- js-measure-fps.md — Verify FPS improvement after optimization
Skill: Concurrent React
Use useDeferredValue and useTransition to improve perceived performance by prioritizing critical updates.
Quick Pattern
Incorrect (blocks input on every keystroke):
const [query, setQuery] = useState('');
<TextInput value={query} onChangeText={setQuery} />
<ExpensiveList query={query} /> // Blocks typingCorrect (input stays responsive):
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
<TextInput value={query} onChangeText={setQuery} />
<ExpensiveList query={deferredQuery} /> // Deferred updateWhen to Use
- Search/filter inputs feel laggy with large result sets
- Expensive computations block UI interactions
- Loading states appear too frequently
- Want to show stale content while loading new content
- Need to prioritize user input over background updates
Prerequisites
- React Native with New Architecture enabled (default in RN 0.76+)
- React 18+ features (
useDeferredValue,useTransition,Suspense)
Concept Overview
Concurrent React allows updates to be:
- Paused: Low-priority work can wait
- Interrupted: User input takes priority
- Abandoned: Outdated updates can be skipped
Step-by-Step Instructions
Pattern 1: Defer Expensive Rendering with useDeferredValue
Use when a value drives expensive computation but you want input to stay responsive.
import { useState, useDeferredValue } from 'react';
const SearchScreen = () => {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// query updates immediately (input stays responsive)
// deferredQuery updates when React has time
return (
<View>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search..."
/>
{/* ExpensiveList receives deferred value */}
<ExpensiveList query={deferredQuery} />
</View>
);
};Pattern 2: Show Stale Content While Loading
const SearchWithStaleIndicator = () => {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<View>
<TextInput value={query} onChangeText={setQuery} />
<View style={isStale && { opacity: 0.7 }}>
<SearchResults query={deferredQuery} />
</View>
{isStale && <ActivityIndicator />}
</View>
);
};Pattern 3: Transition Non-Critical Updates with useTransition
Use when you have multiple state updates and want to mark some as low-priority.
import { useState, useTransition } from 'react';
const TransitionExample = () => {
const [count, setCount] = useState(0);
const [heavyData, setHeavyData] = useState(null);
const [isPending, startTransition] = useTransition();
const handleIncrement = () => {
// High priority - updates immediately
setCount(c => c + 1);
// Low priority - can be interrupted
startTransition(() => {
setHeavyData(computeExpensiveData());
});
};
return (
<View>
<Text>Count: {count}</Text>
{isPending ? <ActivityIndicator /> : <HeavyComponent data={heavyData} />}
<Button onPress={handleIncrement} title="Increment" />
</View>
);
};Pattern 4: Suspense for Data Fetching
import { Suspense, useDeferredValue } from 'react';
const DataScreen = () => {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<View>
<TextInput value={query} onChangeText={setQuery} />
<Suspense fallback={<LoadingSpinner />}>
<SearchResults query={deferredQuery} />
</Suspense>
</View>
);
};Code Examples
Slow Component Optimization
// Without Concurrent React - UI freezes
const SlowSearch = () => {
const [query, setQuery] = useState('');
return (
<>
<TextInput value={query} onChangeText={setQuery} />
<SlowComponent query={query} /> {/* Blocks every keystroke */}
</>
);
};
// With Concurrent React - UI stays responsive
const FastSearch = () => {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<TextInput value={query} onChangeText={setQuery} />
<SlowComponent query={deferredQuery} />
</>
);
};
// Important: Wrap SlowComponent in memo to prevent re-renders from parent
const SlowComponent = memo(({ query }) => {
// Expensive computation here
});Automatic Batching (React 18+)
React 18 automatically batches state updates:
// Before React 18 - 2 re-renders
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Rendered twice
}, 1000);
// React 18+ - 1 re-render (automatic batching)
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// Rendered once!
}, 1000);When to Use Which
| Scenario | Solution |
|---|---|
| Single value drives expensive render | useDeferredValue |
| Multiple state updates, some non-critical | useTransition |
| Need loading indicator for transition | useTransition (has isPending) |
| Data fetching with loading states | Suspense + useDeferredValue |
| Simple parent-to-child value deferral | useDeferredValue |
Important Considerations
1. Wrap expensive components in `memo()`: Without memoization, the component re-renders from parent anyway.
2. Use with New Architecture: Concurrent features require New Architecture in React Native.
3. Don't overuse: Only defer truly expensive work. Adding complexity for fast components is counterproductive.
Common Pitfalls
- Forgetting memo():
useDeferredValueis useless if child re-renders from parent - Using for simple state: Overhead isn't worth it for cheap updates
- Expecting faster computation: These hooks don't make code faster, they prioritize what runs when
Related Skills
- js-profile-react.md - Identify slow components
- js-react-compiler.md - Automatic memoization
- js-lists-flatlist-flashlist.md - For list-specific optimizations
Skill: Higher-Order Lists
Replace ScrollView with FlatList or FlashList for performant large list rendering.
Quick Pattern
Incorrect:
<ScrollView>
{items.map((item) => <Item key={item.id} {...item} />)}
</ScrollView>Correct:
<FlashList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Item {...item} />}
/>When to Use
- Rendering more than 10-20 items in a list
- List scrolling is choppy or laggy
- App freezes when loading list data
- Memory usage spikes with long lists
Prerequisites
@shopify/flash-listfor FlashList (recommended)- Understanding of list virtualization
Version Guardrail
- FlashList v1:
estimatedItemSizeis part of the optimization guidance. - FlashList v2 and newer:
estimatedItemSize,estimatedListSize, andestimatedFirstItemOffsetare deprecated and no longer used. Do not flag them as missing. - Before suggesting a FlashList fix, confirm the installed major version and tailor the advice. See FlashList v2 changes.
Step-by-Step Instructions
1. Identify the Problem
!FPS Drop Graph
The FPS graph shows a severe performance problem during list rendering:
- FPS starts at ~60 (smooth)
- Drops to ~3 FPS during heavy list operation
- Recovers after rendering completes
// BAD: ScrollView renders ALL items at once
const BadList = ({ items }) => (
<ScrollView>
{items.map((item, index) => (
<View key={index}>
<Text>{item}</Text>
</View>
))}
</ScrollView>
);With 5000 items, this creates 5000 views immediately, causing:
- Multi-second freeze
- FPS drop to 0
- High memory usage
2. Replace with FlatList
import { FlatList } from 'react-native';
const BetterList = ({ items }) => {
const renderItem = ({ item }) => (
<View>
<Text>{item}</Text>
</View>
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item, index) => index.toString()}
/>
);
};FlatList only renders visible items + buffer (windowing).
3. Optimize FlatList with getItemLayout
For fixed-height items, skip layout measurement:
const ITEM_HEIGHT = 50;
const OptimizedList = ({ items }) => {
const renderItem = ({ item }) => (
<View style={{ height: ITEM_HEIGHT }}>
<Text>{item}</Text>
</View>
);
const getItemLayout = (_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
});
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item, index) => index.toString()}
getItemLayout={getItemLayout}
/>
);
};4. Upgrade to FlashList (Best Performance)
npm install @shopify/flash-listimport { FlashList } from '@shopify/flash-list';
const BestList = ({ items }) => {
const renderItem = ({ item }) => (
<View style={{ height: 50 }}>
<Text>{item}</Text>
</View>
);
return (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
);
};For FlashList v1, add estimatedItemSize with a realistic average item height. For FlashList v2+, skip that prop and focus on stable keys, lightweight item components, and getItemType when item shapes differ.
FlashList advantages:
- Recycles views instead of creating new ones
- 78/100 vs 25/100 performance score in benchmarks
- Smoother scrolling at ~54 FPS vs lower for FlatList
Code Examples
Variable Height Items (FlashList v1)
// Calculate average for estimatedItemSize
// Items are 50px, 100px, 150px
// Average: (50 + 100 + 150) / 3 = 100px
<FlashList
data={items}
renderItem={renderItem}
estimatedItemSize={100}
/>Mixed Item Types
<FlashList
data={items}
renderItem={({ item }) => {
if (item.type === 'header') return <Header {...item} />;
if (item.type === 'product') return <Product {...item} />;
return <DefaultItem {...item} />;
}}
getItemType={(item) => item.type} // Helps recycling
/>If the project is still on FlashList v1, keep estimatedItemSize alongside getItemType.
FlatList Optimizations (if not using FlashList)
<FlatList
data={items}
renderItem={renderItem}
// Performance props
removeClippedSubviews={true}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
initialNumToRender={10}
windowSize={5}
// Avoid re-renders
keyExtractor={(item) => item.id}
extraData={selectedId} // Only when selection changes
/>Performance Comparison
| Component | 5000 Items Load | Scroll FPS | Memory |
|---|---|---|---|
| ScrollView | 1-3 seconds freeze | < 30 | High |
| FlatList | ~100ms | ~45 | Medium |
| FlashList | ~50ms | ~54 | Low |
Decision Matrix
| Scenario | Recommendation |
|---|---|
| < 20 static items | ScrollView OK |
| 20-100 items | FlatList minimum |
| > 100 items | FlashList |
| Complex item layouts | FlashList with getItemType |
| Fixed height items | FlatList: getItemLayout; FlashList v1: estimatedItemSize; FlashList v2+: stable item structure |
Common Pitfalls
- Inline renderItem functions: Causes re-renders. Define outside or use
useCallback. - Missing keyExtractor: Use unique IDs, not array index when possible.
- Assuming all FlashList versions need `estimatedItemSize`: FlashList v2 ignores it. Check the installed version before suggesting it.
- Heavy item components: Keep list items light. Move side effects out.
Related Skills
- js-profile-react.md - Profile list rendering
- js-measure-fps.md - Measure scroll performance
Skill: Measure JS FPS
Monitor and measure JavaScript frame rate to quantify app smoothness and identify performance regressions.
Quick Command
# Method 1: Built-in Perf Monitor
# Shake device → Dev Menu → "Perf Monitor"
# Method 2: Flashlight (Android, detailed reports)
# Install Flashlight from an official, verified release channel first.
flashlight measureWhen to Use
- Animations feel choppy or janky
- Scrolling is not smooth
- Need baseline FPS metrics before/after optimization
- Want to compare performance across builds
Prerequisites
- React Native app running on device/simulator
- For Flashlight: Android device (iOS not supported)
Note: This skill involves interpreting visual output (FPS graphs, performance overlays). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing metrics manually, or await MCP-based visual feedback integration (see roadmap).
Step-by-Step Instructions
Method 1: React Perf Monitor (Quick Check)
1. Open Dev Menu:
- iOS Simulator:
Ctrl + Cmd + Zor Device > Shake - Android Emulator:
Cmd + M(Mac) /Ctrl + M(Windows)
2. Select "Perf Monitor"
3. Observe the overlay showing:
- UI (Main) thread FPS - Native rendering
- JS thread FPS - JavaScript execution
- RAM usage
4. Hide with "Hide Perf Monitor" from Dev Menu
Interpretation:
- 60 FPS = Smooth (16.6ms per frame)
- < 60 FPS = Dropping frames
- 120 FPS target for high refresh rate devices (8.3ms per frame)
Method 2: Flashlight (Automated Benchmarking)
Android only. Provides detailed reports and JSON export.
!Flashlight FlatList vs FlashList Comparison
Flashlight shows comparative performance data:
- Score (0-100): Overall performance rating (higher is better)
- Average FPS: Target 60 FPS for smooth scrolling
- FPS Graph: Real-time frame rate over test duration
- CPU/RAM metrics: Resource consumption
The image shows FlatList (score: 3) vs FlashList (score: 67) - a dramatic difference visible in both the score and FPS graph.
Installation:
Install Flashlight from the vendor's official release channel before using it. Prefer a package manager or a version-pinned binary with checksum/signature verification. Do not pipe a remote install script directly into a shell.
Usage:
# Start measuring (app must be running on Android)
flashlight measureFeatures:
- Real-time FPS graph
- Average FPS calculation
- CPU and RAM metrics
- Overall performance score
- JSON export for CI comparison
Important: Disable Dev Mode
Always disable development mode for accurate measurements:
Android: 1. Open Dev Menu 2. Settings > JS Dev Mode → OFF
iOS (React Native CLI):
# Run Metro in production mode
npx react-native start --reset-cache
# Then build release variantExpo:
# Start Metro without dev mode
npx expo start --no-dev --minify
# For accurate measurements, use EAS Build for release testingCode Examples
Identify FPS Drop Source
If UI FPS drops but JS FPS is fine:
- Native rendering issue
- Too many views/complex layouts
- Heavy native animations
If JS FPS drops but UI FPS is fine:
- JavaScript computation blocking
- Expensive React re-renders
- Look for
longRunningFunctionpatterns
If Both drop:
- Mixed issue, start with JS profiling
Target Frame Budgets
// 60 FPS = 16.6ms per frame
const FRAME_BUDGET_60 = 16.6;
// 120 FPS = 8.3ms per frame
const FRAME_BUDGET_120 = 8.3;
// If your function takes longer, it will drop frames
const longRunningFunction = () => {
let i = 0;
while (i < 1000000000) { // This blocks for seconds!
i++;
}
};Interpreting Results
| FPS Range | User Perception | Action |
|---|---|---|
| 55-60 | Smooth | Acceptable |
| 45-55 | Slight stutter | Investigate |
| 30-45 | Noticeable jank | Optimize required |
| < 30 | Very choppy | Critical fix needed |
Flashlight CI Integration
# Export measurements to JSON
flashlight measure --output results.json
# Compare builds
flashlight compare baseline.json current.jsonCommon Pitfalls
- Measuring in dev mode: Results will be artificially slow
- Not using real device: Simulators don't reflect real performance
- Ignoring UI thread: React Native has two threads - JS issues don't always show on UI thread
- Single measurement: Run multiple times, FPS varies
Related Skills
- js-profile-react.md - Find what's causing FPS drops
- js-animations-reanimated.md - Fix animation-related drops
- js-bottomsheet.md - Measure bottom sheet gesture and snap performance
- js-lists-flatlist-flashlist.md - Fix scroll-related drops
Skill: Hunt JS Memory Leaks
Find and fix JavaScript memory leaks using React Native DevTools memory profiling.
Quick Pattern
Incorrect (listener not cleaned up):
useEffect(() => {
const sub = EventEmitter.addListener('event', handler);
// Missing cleanup!
}, []);Correct (proper cleanup):
useEffect(() => {
const sub = EventEmitter.addListener('event', handler);
return () => sub.remove();
}, []);When to Use
- App memory usage grows over time
- App crashes after extended use
- Navigating between screens increases memory
- Suspecting event listeners or timers not cleaned up
Prerequisites
- React Native DevTools accessible
- App running in development mode
Step-by-Step Instructions
1. Open Memory Profiler
1. Launch React Native DevTools (press j in Metro) 2. Go to Memory tab 3. Select "Allocation instrumentation on timeline"
2. Record Memory Allocations
1. Click "Start" at the bottom 2. Perform actions that might leak (navigate, trigger events, etc.) 3. Wait 10-30 seconds 4. Click "Stop"
3. Analyze the Timeline
Key indicators:
- Blue bars = Memory allocated
- Gray bars = Memory freed (garbage collected)
- Blue bars that stay blue = Potential leak!
4. Investigate Leaking Objects
!Memory Heap Snapshot
The Memory tab shows:
- Timeline (top): Blue bars = allocations, select time range to filter
- Summary view (bottom): Lists constructors with allocation counts
Key columns:
- Constructor: Object type (e.g.,
JSObject,Function,(string)) - Count: Number of instances (×85000 = 85,000 objects)
- Shallow Size: Memory of the object itself
- Retained Size: Memory freed if object is deleted (including references)
Red flag: Large retained size % with small shallow size % = closures or references holding large objects.
To investigate: 1. Click on a blue spike in the timeline 2. Look at the Constructor list below 3. Check Shallow size vs Retained size 4. Expand constructors to see individual allocations 5. Click to see the exact source location
5. Verify the Fix
After fixing, re-profile. All bars should turn gray (except the most recent).
Code Examples
Common Leak Patterns
1. Listeners Not Cleaned Up:
// BAD: Memory leak
const BadEventComponent = () => {
useEffect(() => {
const subscription = EventEmitter.addListener('myEvent', handleEvent);
// Missing cleanup!
}, []);
return <Text>Listening...</Text>;
};
// GOOD: Proper cleanup
const GoodEventComponent = () => {
useEffect(() => {
const subscription = EventEmitter.addListener('myEvent', handleEvent);
return () => subscription.remove(); // Cleanup!
}, []);
return <Text>Listening...</Text>;
};2. Timers Not Cleared:
// BAD: Memory leak
const BadTimerComponent = () => {
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
// Missing cleanup!
}, []);
};
// GOOD: Proper cleanup
const GoodTimerComponent = () => {
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer); // Cleanup!
}, []);
};3. Closures Capturing Large Objects:
// BAD: Closure captures entire array
class BadClosureExample {
private largeData = new Array(1000000).fill('data');
createLeakyFunction() {
return () => this.largeData.length; // Captures this.largeData
}
}
// GOOD: Only capture what's needed
class GoodClosureExample {
private largeData = new Array(1000000).fill('data');
createEfficientFunction() {
const length = this.largeData.length; // Extract value
return () => length; // Only captures primitive
}
}4. Global Arrays Growing:
// BAD: Global array never cleared
let leakyClosures = [];
const createLeak = () => {
const data = generateLargeData();
leakyClosures.push(() => data); // Keeps growing!
};
// GOOD: Clear when done or use WeakRef
const createNoLeak = () => {
const data = generateLargeData();
const closure = () => data;
// Use it and let it be garbage collected
return closure;
};Memory Profiler Metrics
| Metric | Meaning |
|---|---|
| Shallow size | Memory held by the object itself |
| Retained size | Memory freed if object is deleted (includes references) |
Large retained size with small shallow size = Object holding references to other large objects (common in closures).
Common Pitfalls
- Not forcing GC: GC runs periodically. Allocate something else to trigger collection before concluding there's a leak.
- Ignoring gray bars: Gray = properly collected. Only blue bars that persist are leaks.
- Missing useEffect cleanup: Most common React Native leak source.
Related Skills
- native-memory-leaks.md - Native-side memory leaks
- js-profile-react.md - General profiling
Skill: Profile React Performance
Identify unnecessary re-renders and performance bottlenecks in React Native apps using React Native DevTools.
Quick Command
# Open React Native DevTools (press 'j' in Metro terminal)
# Or shake device → "Open DevTools"
# Go to Profiler tab → Start profiling → Perform actions → StopFor targeted audits, profile the exact flow under review. Baseline output should include commit timeline, re-render counts, slow components, and a breakdown of the heaviest commit.
When to Use
- App feels sluggish or janky during interactions
- Need to identify which components re-render unnecessarily
- Investigating slow list scrolling or form inputs
- Before applying memoization or state management changes
Prerequisites
- React Native DevTools accessible (press
jin Metro or use Dev Menu) - App running in development mode
- React DevTools version 6.0.1+ for React Compiler support
Note: This skill involves interpreting visual profiler output (flame graphs, component highlighting). AI agents cannot yet process screenshots autonomously. Use this as a guide while reviewing the profiler UI manually, or await MCP-based visual feedback integration (see roadmap).
Step-by-Step Instructions
1. Open React Native DevTools
# Option A: Press 'j' in Metro terminal (works with both RN CLI and Expo)
# Option B: Shake device / Cmd+D (iOS) / Cmd+M (Android) → "Open DevTools"
# Expo: Also accessible via Expo DevTools in browser2. Configure Profiler Settings
1. Go to Profiler tab 2. Click gear icon (⚙️) for settings 3. Enable:
- "Highlight updates when components render"
- "Record why each component rendered while profiling"
3. Record a Profiling Session
1. Click "Start profiling" (blue circle) or "Reload and start profiling"
2. Perform the exact interaction or navigation flow you want to analyze
3. Click "Stop profiling"Use "Reload and start profiling" for startup performance analysis.
For AI-agent workflows, treat this as a required sequence:
1. Start profiling. 2. Drive the audited flow, not just app startup or idle state. 3. Stop profiling. 4. Inspect commit timeline, re-renders, slow components, and the heaviest commit before proposing fixes.
4. Analyze the Flame Graph
!React DevTools Flamegraph
The flame graph shows component render hierarchy with timing:
Color indicators:
- Yellow components: Most time spent rendering (focus here)
- Green components: Fast/memoized
- Gray components: Did not render
Right panel shows "Why did this render?":
- Props changed (shows which prop, e.g.,
children,onPress) - Rendered at timestamps with duration (e.g., "3.7s for 0.9ms")
Click on a component to see:
- Why it rendered (hook change, props change, parent re-render)
- Render duration
- Child components affected
5. Use Ranked View for Bottom-Up Analysis
Click "Ranked" tab to see components sorted by render time (slowest first).
6. Profile JavaScript CPU
For non-React performance issues:
1. Go to JavaScript Profiler tab (enable in settings if hidden) 2. Click "Start" to record 3. Perform actions 4. Click "Stop" 5. Use Heavy (Bottom Up) view to find slowest functions
Code Examples
Before: Unnecessary Re-renders
const App = () => {
const [count, setCount] = useState(0);
return (
<View>
<Text>{count}</Text>
{/* Button re-renders on every count change */}
<Button onPress={() => setCount(count + 1)} title="Press" />
</View>
);
};
const Button = ({onPress, title}) => (
<Pressable onPress={onPress}>
<Text>{title}</Text>
</Pressable>
);After: Memoized
const App = () => {
const [count, setCount] = useState(0);
const onPressHandler = useCallback(() => setCount(c => c + 1), []);
return (
<View>
<Text>{count}</Text>
<Button onPress={onPressHandler} title="Press" />
</View>
);
};
const Button = memo(({onPress, title}) => (
<Pressable onPress={onPress}>
<Text>{title}</Text>
</Pressable>
));Interpreting Results
| Symptom | Likely Cause | Solution |
|---|---|---|
| Many yellow components | Cascading re-renders | Add memoization or use React Compiler |
| "Props changed" on callbacks | Inline functions recreated | Use useCallback |
| "Parent component rendered" | State too high in tree | Move state down or use atomic state |
| Long JS thread block | Heavy computation | Move to background or use useDeferredValue |
Only propose callback or dependency-array changes when the profiler or a reproducible bug shows they matter. Do not infer stale closures from a snippet alone.
Common Pitfalls
- Profiling in dev mode: Always disable JS Dev Mode for accurate measurements (Settings > JS Dev Mode on Android)
- Not using production builds: Some issues only appear with minified code
- Ignoring "Why did this render?": This tells you exactly what to fix
- Using component tree depth or count as the main baseline: These are secondary context, not the core performance signal
Related Skills
- js-react-compiler.md - Automatic memoization
- js-atomic-state.md - Reduce re-renders with Jotai/Zustand
- js-bottomsheet.md - Profile bottom sheet callback-driven re-renders
- js-measure-fps.md - Quantify frame rate impact
Skill: React Compiler
Set up React Compiler to automatically memoize components and eliminate unnecessary re-renders.
Quick Pattern
Before (manual memoization):
const MemoizedButton = memo(({ onPress }) => <Pressable onPress={onPress} />);
const handler = useCallback(() => doSomething(), []);After (automatic with React Compiler):
// No memo/useCallback needed - compiler handles it
const Button = ({ onPress }) => <Pressable onPress={onPress} />;
const handler = () => doSomething();When to Use
- Want automatic performance optimization without manual
memo/useMemo/useCallback - Codebase follows Rules of React
- React Native 0.76+ or Expo SDK 52+
- Ready to remove boilerplate memoization code
Prerequisites
- React 17+ (React 19 recommended for best compatibility)
- Babel-based build system
- Code follows Rules of React
Step-by-Step Instructions
Step 1: Check Compatibility
Before enabling the compiler, verify your project is compatible:
npx react-compiler-healthcheck@latestThis checks if your app follows the Rules of React and identifies potential issues.
Step 2: Install React Compiler
Expo Projects
SDK 54 and later (simplified setup):
npx expo install babel-plugin-react-compilerSDK 52-53:
npx expo install babel-plugin-react-compiler@beta react-compiler-runtime@betaThen enable in your app config:
// app.json
{
"expo": {
"experiments": {
"reactCompiler": true
}
}
}React Native (without Expo)
npm install -D babel-plugin-react-compiler@latestFor React Native < 0.78 (React < 19), also install the runtime:
npm install react-compiler-runtime@betaStep 3: Configure Babel (React Native without Expo)
For non-Expo React Native projects, configure Babel manually:
// babel.config.js
const ReactCompilerConfig = {
target: '19', // Use '18' for React Native < 0.78
};
module.exports = function (api) {
api.cache(true);
return {
presets: ['module:@react-native/babel-preset'],
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig], // Must run first!
// ... other plugins
],
};
};Important: React Compiler must run first in your Babel plugin pipeline. The compiler needs the original source information for proper analysis.
Step 4: Set Up ESLint (Recommended)
The ESLint plugin helps identify code that can't be optimized and enforces the Rules of React.
Expo Projects
npx expo lint # Ensures ESLint is set up
npx expo install eslint-plugin-react-compiler -- -DConfigure ESLint:
// .eslintrc.js
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const reactCompiler = require('eslint-plugin-react-compiler');
module.exports = defineConfig([
expoConfig,
reactCompiler.configs.recommended,
{
ignores: ['dist/*'],
},
]);React Native (without Expo)
npm install -D eslint-plugin-react-hooks@latestThe compiler rules are available in the recommended-latest preset. Follow the eslint-plugin-react-hooks installation instructions.
Step 5: Verify Optimizations
Open React DevTools. Optimized components show a Memo ✨ badge.
You can also verify by checking build output—compiled code includes automatic memoization:
import { c as _c } from 'react/compiler-runtime';
export default function MyApp() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for('react.memo_cache_sentinel')) {
t0 = <div>Hello World</div>;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}Note: React Native 0.76+ includes DevTools with Memo badge support by default. For older versions or third-party debuggers with version mismatches, you may need to override react-devtools-core in package.json.
Incremental Adoption
You can incrementally adopt React Compiler using two strategies:
Strategy 1: Limit to Specific Directories
Configure the Babel plugin to only run on specific files, e.g. src/path/to/dir in the following examples:
Expo (create babel.config.js with npx expo customize babel.config.js):
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
'babel-preset-expo',
{
'react-compiler': {
sources: (filename) => {
return filename.includes('src/path/to/dir');
},
},
},
],
],
};
};React Native (without Expo):
// babel.config.js
const ReactCompilerConfig = {
target: '19',
sources: (filename) => {
return filename.includes('src/path/to/dir');
},
};
module.exports = function (api) {
api.cache(true);
return {
presets: ['module:@react-native/babel-preset'],
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
};
};After changing babel.config.js, restart Metro with cache cleared:
# Expo
npx expo start --clear
# React Native CLI
npx react-native start --reset-cacheStrategy 2: Opt Out Specific Components
Use the "use no memo" directive to skip optimization for specific components or files:
function ProblematicComponent() {
'use no memo';
return <Text>Will not be optimized</Text>;
}This is useful for temporarily opting out components that cause issues. Fix the underlying problem and remove the directive once resolved.
How It Works
The compiler transforms your code to automatically cache values:
Before (your code):
export default function MyApp() {
const [value, setValue] = useState('');
return (
<TextInput onChangeText={() => setValue(value)}>Hello World</TextInput>
);
}After (compiled output):
import { c as _c } from 'react/compiler-runtime';
export default function MyApp() {
const $ = _c(2); // Cache with 2 slots
const [value, setValue] = useState('');
let t0;
if ($[0] !== value) {
t0 = (
<TextInput onChangeText={() => setValue(value)}>Hello World</TextInput>
);
$[0] = value;
$[1] = t0;
} else {
t0 = $[1]; // Return cached JSX
}
return t0;
}Code Examples
React Compiler Playground
Test transformations at React Playground.
What Gets Optimized
// Components - auto-memoized
const Button = ({ onPress, label }) => (
<Pressable onPress={onPress}>
<Text>{label}</Text>
</Pressable>
);
// Callbacks - auto-cached (no useCallback needed)
const handlePress = () => {
console.log('pressed');
};
// Expensive computations - auto-cached (no useMemo needed)
const filtered = items.filter((item) => item.active);What Breaks Compilation
// BAD: Mutating props
const BadComponent = ({ items }) => {
items.push('new item'); // Mutation!
return <List data={items} />;
};
// BAD: Mutating during render
const BadMutation = () => {
const [items, setItems] = useState([]);
items.push('new'); // Mutation during render!
return <List data={items} />;
};
// BAD: Non-idempotent render
let counter = 0;
const BadRender = () => {
counter++; // Side effect during render!
return <Text>{counter}</Text>;
};Should You Remove Manual Memoization?
Improvements are primarily automatic. You can remove instances of useCallback, useMemo, and React.memo in favor of automatic memoization once the compiler is working correctly in your project.
Note: Class components will not be optimized. Migrate to function components for full benefits.
Expo's implementation only runs on application code (not node_modules), and only when bundling for the client (disabled in server rendering).
Expected Performance Improvements
Testing on Expensify app showed:
- 4.3% improvement in Chat Finder TTI
- Significant reduction in cascading re-renders
- Most impact on apps without existing manual optimization
Already heavily optimized apps may see marginal gains.
Common Pitfalls
- Not fixing ESLint errors first: When ESLint reports an error, the compiler skips that component—this is safe but means you miss optimization
- Expecting it to fix bad patterns: Compiler optimizes good code, doesn't fix bad code
- Forgetting shallow comparison: Like
memo, compiler uses shallow comparison for objects/arrays - Not running healthcheck: Always run
npx react-compiler-healthcheck@latestbefore enabling
Related Skills
- js-profile-react.md - Verify optimization impact
- js-atomic-state.md - Alternative for state-related re-renders
Related skills
FAQ
What performance areas does react-native-best-practices cover?
react-native-best-practices addresses FPS, time-to-interactive, bundle size, memory leaks, re-renders, animations, Hermes tuning, bridge overhead, FlashList, and native module bottlenecks.
Does react-native-best-practices support Expo projects?
react-native-best-practices applies to React Native and Expo apps, with metadata tags for react-native, expo, performance, optimization, and profiling from Callstack.
Is React Native Best Practices safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.