react-native-best-practices
Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops.
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".
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 remote chunk loading as first-party artifact delivery only. Prefer app-bundled chunks or signed CI release manifests; hosted 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-* |
Impact labels are triage hints: CRITICAL first, HIGH next, MEDIUM when evidence points there.
Quick Reference
Optimization Workflow
Follow this cycle for any performance issue: Measure → Optimize → Re-measure → Validate
- 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. Do not recommend memoization, atomic state, or compiler changes without a measured render or FPS problem.
- Optimize: Apply the targeted fix from the relevant reference
- Re-measure: Run the same measurement to get updated metrics
- 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:
agent-device react-devtools status
agent-device react-devtools wait --connected
agent-device react-devtools profile start
agent-device react-devtools profile stop
agent-device react-devtools profile slow --limit 5
agent-device react-devtools profile rerenders --limit 5
agent-device react-devtools profile timeline --limit 20Drive the target interaction with normal agent-device commands between profile start and profile stop.
Manual fallback when agent-device is unavailable: open React Native DevTools from Metro (j) or the Dev Menu, use the Profiler tab, and record the same interaction.
For release-build React component profiling, connect @callstack/inspector first so React DevTools can attach to the release app, then run the agent-device react-devtools flow above.
Common fixes:
- Replace ScrollView with FlatList/FlashList/Legend List for long lists
- After profiling shows cascading re-renders, use React Compiler for automatic memoization
- After profiling shows broad store/context updates, 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
- Evaluate tree shaking (Expo SDK 52+ experimental unused import/export removal, or Re.Pack only if already configured)
- 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:
- For React Native 0.78 and earlier, disable Android JS bundle compression to enable 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/:
JavaScript/React (js-*)
| File | Impact | Description |
|---|---|---|
| js-lists-flatlist-flashlist.md (references/js-lists-flatlist-flashlist.md) | CRITICAL | Replace ScrollView with virtualized lists |
| js-profile-react.md (references/js-profile-react.md) | MEDIUM | agent-device react-devtools profiling |
| js-measure-fps.md (references/js-measure-fps.md) | HIGH | FPS monitoring and measurement |
| js-memory-leaks.md (references/js-memory-leaks.md) | MEDIUM | JS memory leak hunting |
| js-atomic-state.md (references/js-atomic-state.md) | HIGH | Jotai/Zustand patterns |
| js-concurrent-react.md (references/js-concurrent-react.md) | HIGH | useDeferredValue, useTransition |
| js-react-compiler.md (references/js-react-compiler.md) | HIGH | Automatic memoization |
| js-animations-reanimated.md (references/js-animations-reanimated.md) | MEDIUM | Reanimated worklets |
| js-bottomsheet.md (references/js-bottomsheet.md) | HIGH | Bottom sheet optimization |
| js-uncontrolled-components.md (references/js-uncontrolled-components.md) | HIGH | TextInput optimization |
Native (native-*)
| File | Impact | Description |
|---|---|---|
| native-turbo-modules.md (references/native-turbo-modules.md) | HIGH | Building fast native modules |
| native-sdks-over-polyfills.md (references/native-sdks-over-polyfills.md) | HIGH | Native vs JS libraries |
| native-measure-tti.md (references/native-measure-tti.md) | HIGH | TTI measurement setup |
| native-threading-model.md (references/native-threading-model.md) | HIGH | Turbo Module threads |
| native-profiling.md (references/native-profiling.md) | MEDIUM | Xcode/Android Studio profiling |
| native-platform-setup.md (references/native-platform-setup.md) | MEDIUM | iOS/Android tooling guide |
| native-view-flattening.md (references/native-view-flattening.md) | MEDIUM | View hierarchy debugging |
| native-memory-patterns.md (references/native-memory-patterns.md) | MEDIUM | C++/Swift/Kotlin memory |
| native-memory-leaks.md (references/native-memory-leaks.md) | MEDIUM | Native memory leak hunting |
| native-android-16kb-alignment.md (references/native-android-16kb-alignment.md) | CRITICAL | Third-party library alignment for Google Play |
Bundling (bundle-*)
| File | Impact | Description |
|---|---|---|
| bundle-barrel-exports.md (references/bundle-barrel-exports.md) | CRITICAL | Avoid barrel imports |
| bundle-analyze-js.md (references/bundle-analyze-js.md) | CRITICAL | JS bundle visualization |
| bundle-tree-shaking.md (references/bundle-tree-shaking.md) | HIGH | Dead code elimination |
| bundle-analyze-app.md (references/bundle-analyze-app.md) | HIGH | App size analysis |
| bundle-r8-android.md (references/bundle-r8-android.md) | HIGH | Android code shrinking |
| bundle-hermes-mmap.md (references/bundle-hermes-mmap.md) | HIGH | Disable bundle compression |
| bundle-native-assets.md (references/bundle-native-assets.md) | HIGH | Asset catalog setup |
| bundle-library-size.md (references/bundle-library-size.md) | MEDIUM | Evaluate dependencies |
| bundle-code-splitting.md (references/bundle-code-splitting.md) | MEDIUM | Remote chunk loading safeguards |
Problem → Skill Mapping
| Problem | Start With |
|---|---|
| App feels slow/janky | js-measure-fps.md (references/js-measure-fps.md) → js-profile-react.md (references/js-profile-react.md) |
| Too many re-renders | js-profile-react.md (references/js-profile-react.md) → js-react-compiler.md (references/js-react-compiler.md) |
| Slow startup (TTI) | native-measure-tti.md (references/native-measure-tti.md) → bundle-analyze-js.md (references/bundle-analyze-js.md) |
| Large app size | bundle-analyze-app.md (references/bundle-analyze-app.md) → bundle-r8-android.md (references/bundle-r8-android.md) |
| Memory growing | js-memory-leaks.md (references/js-memory-leaks.md) or native-memory-leaks.md (references/native-memory-leaks.md) |
| Animation drops frames | js-animations-reanimated.md (references/js-animations-reanimated.md) |
| Bottom sheet jank/re-renders | js-bottomsheet.md (references/js-bottomsheet.md) → js-animations-reanimated.md (references/js-animations-reanimated.md) |
| List scroll jank | js-lists-flatlist-flashlist.md (references/js-lists-flatlist-flashlist.md) |
| TextInput lag | js-uncontrolled-components.md (references/js-uncontrolled-components.md) |
| Native module slow | native-turbo-modules.md (references/native-turbo-modules.md) → native-threading-model.md (references/native-threading-model.md) |
| Native library alignment issue | native-android-16kb-alignment.md (references/native-android-16kb-alignment.md) |
Attribution
Based on "The Ultimate Guide to React Native Optimization" by Callstack.
- SKILL.md
- POWER.md
- agents/openai.yaml
- references/bundle-analyze-app.md
- references/bundle-analyze-js.md
- references/bundle-barrel-exports.md
- references/bundle-code-splitting.md
- references/bundle-hermes-mmap.md
- references/bundle-library-size.md
- references/bundle-native-assets.md
- references/bundle-r8-android.md
- references/bundle-tree-shaking.md
- references/images/bundle-treemap-source-map-explorer.png
- references/images/controlled-textinput-pingpong.png
- references/images/devtools-flamegraph.png
- references/images/emerge-xray-ios.png
- references/images/expo-atlas-treemap.png
- references/images/flashlight-flatlist-vs-flashlist.png
- references/images/fps-drop-graph.png
- references/images/memory-heap-snapshot.png
- references/images/tti-warm-start-diagram.png
- references/images/view-hierarchy-flattening.png
- references/images/xcode-instruments-templates.png
- references/images/xcode-thread-view.png
- references/js-animations-reanimated.md
- references/js-atomic-state.md
- references/js-bottomsheet.md
- references/js-concurrent-react.md
- references/js-lists-flatlist-flashlist.md
- references/js-measure-fps.md
- references/js-memory-leaks.md
- references/js-profile-react.md
- references/js-react-compiler.md
- references/js-uncontrolled-components.md
- references/native-android-16kb-alignment.md
- references/native-measure-tti.md
- references/native-memory-leaks.md
- references/native-memory-patterns.md
- references/native-platform-setup.md
- references/native-profiling.md
- references/native-sdks-over-polyfills.md
- references/native-threading-model.md
- references/native-turbo-modules.md
- references/native-view-flattening.md
SKILL.md
SKILL.md holds the skill's instructions; it is edited on the Instructions tab.
POWER.md
name: react-native-best-practices
description: Provides React Native performance optimization guidelines for FPS, TTI, bundle size, memory leaks, re-renders, and animations. Applies to tasks involving Hermes optimization, JS thread blocking, bridge overhead, FlashList, native modules, or debugging jank and frame drops.
license: MIT
author: Callstack
keywords: ["react-native", "expo", "performance", "optimization", "profiling"]
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
- Verify with:
- Metro bundler is running (apply only for bundle analysis)
- React Native DevTools profiling is available through
agent-device react-devtools(apply only for React render profiling/debugging)- Run
agent-device react-devtools status, thenagent-device react-devtools wait --connected
- Run
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 remote chunk loading, prefer app-bundled chunks or signed CI release manifests; hosted chunks must be first-party artifacts 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
agent-device react-devtools status
agent-device react-devtools wait --connected
agent-device react-devtools profile start
agent-device react-devtools profile stop
agent-device react-devtools profile slow --limit 5
agent-device react-devtools profile rerenders --limit 5
agent-device react-devtools profile timeline --limit 20Drive the target interaction with normal agent-device commands between profile start and profile stop.
Manual fallback when agent-device is unavailable: open React Native DevTools from Metro (j) or the Dev Menu, use the Profiler tab, and record the same interaction.
For release-build React component profiling, connect @callstack/inspector first so React DevTools can attach to the release app, then run the agent-device react-devtools flow above.
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
- After profiling shows cascading re-renders, use React Compiler for automatic memoization
- After profiling shows broad store/context updates, 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
- Evaluate tree shaking (Expo SDK 52+ experimental unused import/export removal, or Re.Pack only if already configured)
- Enable R8 for Android native code shrinking
Measure TTI
- Use
react-native-performancefor markers - Only measure cold starts (exclude warm/hot/prewarm)
Common fixes:
- For React Native 0.78 and earlier, disable Android JS bundle compression to enable 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.
agents/openai.yaml
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."
references/bundle-analyze-app.md
title: Analyze App Bundle Size
impact: HIGH
tags: app-size, ruler, emerge-tools, thinning
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 visual size reports (Ruler, Emerge Tools X-Ray). When regression checks include device flows, use
agent-devicefor app evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. Size report analysis itself may still require exported reports, browser screenshots, or human review. Record concrete module/file names and before/after artifact sizes in text when asking an agent to reason about them.
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:
- Open App Store Connect
- Go to your build
- View size table by device variant
Note: TestFlight builds include debug data, App Store builds slightly larger due to DRM.
Via Xcode Export
Export an archive with app thinning enabled for 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 (images/emerge-xray-ios.png)
- 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 (./bundle-r8-android.md) - Reduce Android size
- bundle-native-assets.md (./bundle-native-assets.md) - Optimize asset delivery
- bundle-analyze-js.md (./bundle-analyze-js.md) - JS bundle analysis
references/bundle-analyze-js.md
title: Analyze JS Bundle Size
impact: CRITICAL
tags: bundle, analysis, source-map-explorer, expo-atlas
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 visual treemap output (source-map-explorer, Expo Atlas). When regression checks include device flows, use
agent-devicefor app evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. Treemap analysis itself may still require exported reports, browser screenshots, or human review. Record the largest modules and before/after bundle sizes in text when asking an agent to reason about them.
Understanding Hermes Bytecode
Release builds using Hermes, the default engine in modern React Native, ship Hermes bytecode rather than 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
Development builds fetch JS from the dev server, and non-Hermes engines have different startup tradeoffs. Smaller bytecode helps app size and startup, but startup also depends on what executes on the initialization path. Imports that eagerly touch native modules can defeat Turbo Module lazy loading and hurt 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 (images/bundle-treemap-source-map-explorer.png)
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-atlasExpo Atlas Treemap (images/expo-atlas-treemap.png)
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: Prefer built-ins or 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 (./bundle-barrel-exports.md) - Fix barrel import issues
- bundle-tree-shaking.md (./bundle-tree-shaking.md) - Enable dead code elimination
- bundle-library-size.md (./bundle-library-size.md) - Check library sizes before adding
references/bundle-barrel-exports.md
title: Avoid Barrel Exports
impact: CRITICAL
tags: bundle, imports, barrel, tree-shaking
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
Without effective tree shaking or a library-specific Babel plugin, barrel imports can make all re-exported modules reachable and evaluated even when only one export is used:
// 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';If the project uses a bundler/configuration with working tree shaking, top-level ESM imports from libraries such as date-fns may be optimized automatically. Without that, submodule imports are still the safer pattern. Measure with bundle analysis.
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:
- Run bundle analysis (see bundle-analyze-js.md (./bundle-analyze-js.md))
- Compare sizes before/after
- 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 (./bundle-analyze-js.md) - Verify impact
- bundle-tree-shaking.md (./bundle-tree-shaking.md) - Automatic solution
- bundle-library-size.md (./bundle-library-size.md) - Check library patterns
references/bundle-code-splitting.md
title: Remote Chunk Loading
impact: MEDIUM
tags: code-splitting, lazy-loading, chunks, release-artifacts, remote-code
Skill: Remote Chunk Loading
Harden remote JavaScript chunk loading when a React Native app already uses Re.Pack or has an explicit remote-code-loading requirement.
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 approaches app-store or base-module limits
- The app already has a micro-frontend architecture
- Loading features based on user permissions
- Other bundle-size optimizations are exhausted
- Remote delivery is an explicit product or release requirement
Do not recommend adopting Re.Pack for ordinary bundle-size work. Keep the default path on Metro/Expo unless remote chunk loading is already present or specifically required.
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
Chunks are executable application code. Prefer chunks packaged with the app or resolved from a release manifest produced by your CI. Hosted chunks are acceptable only when they are first-party release artifacts, not arbitrary runtime URLs.
Keep these guardrails in place:
- Serve chunks only from a first-party, HTTPS-only origin you control
- Resolve
scriptIdthrough a fixed allowlist or signed release manifest - If using Re.Pack, enable code signing for remotely hosted chunks and use strict signature verification in production
- Fail closed if a chunk is missing or unexpected
- Do not load chunks from user-controlled input, query params, or third-party domains
Prerequisites
- Project already uses Re.Pack, or remote chunk loading is an explicit requirement approved after measuring simpler alternatives
- Remote chunks are produced by the same release pipeline as the app
- Chunk locations come from a fixed allowlist or signed release manifest
If the project does not already use Re.Pack, do not start here. First confirm Metro/Expo bundle analysis, import cleanup, asset cleanup, native app-size work, and store delivery constraints.
Step-by-Step Instructions
1. 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')
);2. Wrap with Suspense
import React, { Suspense } from 'react';
const App = () => {
return (
<Suspense fallback={<LoadingSpinner />}>
<SettingsScreen />
</Suspense>
);
};3. Configure Chunk Loading
// index.js (before AppRegistry)
import { ScriptManager, Script } from '@callstack/repack/client';
const RELEASE_CHUNKS = Object.freeze({
settings: {
release: '42',
},
});
ScriptManager.shared.addResolver(async (scriptId) => {
if (__DEV__) {
return {
url: Script.getDevServerURL(scriptId),
cache: false,
};
}
const chunk = RELEASE_CHUNKS[scriptId];
if (!chunk) {
throw new Error(`Unknown chunk: ${scriptId}`);
}
return {
url: Script.getRemoteURL(
getFirstPartyChunkBaseURL(scriptId, chunk.release)
),
verifyScriptSignature: 'strict',
};
});
function getFirstPartyChunkBaseURL(scriptId, release) {
// App-owned helper: read a signed CI manifest and return the first-party
// base URL without ".chunk.bundle"; Script.getRemoteURL appends it.
// Do not accept hostnames, paths, or script IDs from runtime input.
return ReleaseManifest.getChunkBaseURL({ scriptId, release });
}
AppRegistry.registerComponent(appName, () => App);For app-bundled chunks in a Re.Pack project, configure extraChunks with type: 'local' and resolve those script IDs from the filesystem:
if (LOCAL_CHUNKS.has(scriptId)) {
return {
url: Script.getFileSystemURL(scriptId),
absolute: true,
};
}4. Build and Deploy Chunks
Build generates:
index.bundle- Main bundlesettings.chunk.bundle- Lazy-loaded chunk
Remote chunks are written to build/output/<platform>/remotes by default. Deploy chunks as first-party release artifacts. Prefer app-bundled assets; if hosted, publish them through CI to an app-owned HTTPS origin and keep the allowlist or signed 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
Only use Module Federation when the app already has a micro-frontend architecture and the organizational boundary is worth the runtime trust boundary:
// Host app loads remote module
const RemoteModule = React.lazy(() =>
import('remote-app/Module')
);Federation increases the trust boundary. Keep the same first-party origin, release-manifest, code-signing, and allowlist rules as above.
Caching Strategy
import AsyncStorage from '@react-native-async-storage/async-storage';
ScriptManager.shared.setStorage(AsyncStorage);Set storage before adding resolvers so Re.Pack can cache resolved script locator data. Return cache: false for dev server chunks or any script that should bypass caching.
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', (script) => {
console.log(`Loading: ${script.scriptId}`);
});
ScriptManager.shared.on('loaded', (script) => {
console.log(`Loaded: ${script.scriptId}`);
});
ScriptManager.shared.on('error', (error) => {
console.error('Script loading failed:', 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: JS chunks from third-party or user-controlled origins are equivalent to remote code execution
Related Skills
- bundle-tree-shaking.md (./bundle-tree-shaking.md) - Tree-shaking caveats
- bundle-analyze-js.md (./bundle-analyze-js.md) - Measure chunk sizes
- native-measure-tti.md (./native-measure-tti.md) - Verify TTI impact
references/bundle-hermes-mmap.md
title: Disable JS Bundle Compression
impact: HIGH
tags: android, hermes, mmap, tti, startup
Skill: Disable JS Bundle Compression
Disable Android JS bundle compression to enable Hermes memory mapping for faster startup on React Native 0.78 and earlier.
Quick Config
// android/app/build.gradle, React Native 0.78 and earlier fallback
android {
androidResources {
noCompress += ["bundle"]
}
}Note: React Native 0.79+ defaults to uncompressed Android JS bundles. Prefer checking/toggling react { enableBundleCompression = false } there instead of adding androidResources.noCompress manually.
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:
- Hermes opens bytecode file
- OS memory-maps directly to disk
- Only pages actually accessed are loaded
- Result: Fast startup, low memory
With compression:
- Android decompresses entire bundle
- Loaded into memory
- Then Hermes processes
- Result: Slower startup, higher memory
Step-by-Step Implementation
Edit build.gradle
For React Native 0.78 and earlier, edit 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 unless the project explicitly enabled bundle compression.
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 (./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 |
|---|---|
| RN 0.78 or earlier startup-critical app | ✅ Enable |
| Storage-sensitive users | ⚠️ Test impact |
| Already fast TTI | Maybe not worth it |
| RN 0.79+ default config | Skip |
Related Skills
- native-measure-tti.md (./native-measure-tti.md) - Measure TTI improvement
- bundle-analyze-app.md (./bundle-analyze-app.md) - Check size impact
- bundle-r8-android.md (./bundle-r8-android.md) - Offset size increase
references/bundle-library-size.md
title: Determine Library Size
impact: MEDIUM
tags: dependencies, bundlephobia, library-size
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 | Rough JS import 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 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
Bundlephobia, pkg-size.dev, and Import Cost measure JavaScript package cost. They do not capture native code added by React Native libraries such as maps, Reanimated, Firebase, camera, video, or analytics SDKs. For native dependencies, always verify the actual IPA/AAB/APK size after installation.
Comparison Workflow
Before Adding Dependency
Check on bundlephobia:
https://bundlephobia.com/package/[package-name]Compare alternatives:
moment (289 kB) vs date-fns (75 kB) vs dayjs (6 kB)Check what you actually need:
- Full library import vs specific functions
- Native alternative available?
After Adding
- Analyze bundle (see bundle-analyze-js.md (./bundle-analyze-js.md))
- Verify actual impact matches expected
- Check for duplicate dependencies
Common Large Dependencies
| Library | Size (gzipped) | Alternative |
|---|---|---|
| moment | ~70 KB | dayjs (~3 KB) |
| lodash (full) | ~25 KB | Built-ins or 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 Rule
Prefer the smallest option that satisfies correctness and platform requirements, then verify the real app artifact. JS package size alone is not enough for React Native dependencies with native code.
Code Example: Optimizing Imports
// BAD: Full library
import _ from 'lodash';
_.get(obj, 'path.to.value');
// BETTER: Specific import
import get from 'lodash/get';
get(obj, 'path.to.value');
// BEST: Native JS
obj?.path?.to?.value;Related Skills
- bundle-analyze-js.md (./bundle-analyze-js.md) - Verify actual bundle impact
- bundle-barrel-exports.md (./bundle-barrel-exports.md) - Optimize how you import
- native-sdks-over-polyfills.md (./native-sdks-over-polyfills.md) - Native alternatives to JS libs
references/bundle-native-assets.md
title: Native Assets
impact: HIGH
tags: assets, images, asset-catalog, app-thinning
Skill: Native Assets
Configure platform-specific asset delivery to reduce app download size.
Quick Config
iOS Asset Catalog (Build Phase):
# Default RN template: the Xcode bundle script cd's to PROJECT_ROOT first.
export EXTRA_PACKAGER_ARGS="--asset-catalog-dest ios"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
Build AAB:
cd android && ./gradlew bundleReleaseMetro 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/ # 4xPlay 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 an asset catalog in the same directory you pass to --asset-catalog-dest:
ios/RNAssets.xcassets/React Native's bundler writes image sets into RNAssets.xcassets under the destination directory. Keep the manual command and Xcode build phase destination consistent.
Step 2: Configure Build Phase
In Xcode, add this before the React Native bundle command in the Bundle React Native code and images build phase:
export EXTRA_PACKAGER_ARGS="--asset-catalog-dest ios"This assumes the default React Native build script, which changes directory to PROJECT_ROOT before invoking Metro. If a custom build phase runs from a different working directory, set --asset-catalog-dest relative to that working directory and verify the generated RNAssets.xcassets path.
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. Separate Bundled Assets from Remote Images
Remote image caching libraries can help runtime image performance, but they do not reduce the size of images already bundled into the app.
Verification
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
- Inconsistent destination paths: The build phase and manual bundle command should point at the same asset catalog parent directory
- 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 the March 2026 book export, iOS asset catalog generation is not described as default. Verify current React Native release notes before applying this manually.
Related Skills
- bundle-analyze-app.md (./bundle-analyze-app.md) - Verify asset impact
- bundle-r8-android.md (./bundle-r8-android.md) - Android code optimization
references/bundle-r8-android.md
title: R8 Code Shrinking
impact: HIGH
tags: android, r8, proguard, minify, shrink
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 basic obfuscation to raise reverse-engineering effort, not as a security boundary
- 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'
}
}
}For Expo projects, wire release minification through android.enableMinifyInReleaseBuilds and resource shrinking through android.enableShrinkResourcesInReleaseBuilds. In managed/prebuild projects, set these through expo-build-properties so they survive expo prebuild.
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 (#common-library-rules) and Troubleshooting (#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:
- Check crash log for class name
- Add keep rule:
-keep class com.example.CrashedClass { *; } - 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 (./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.apkVerify Runtime Behavior
Use agent-device to install or open the release build, navigate critical flows, capture snapshots/screenshots, and collect logs. If it is missing and release verification is needed, install it through the environment's approved/trusted path or ask the user to install or enable it. Read the agent-device skill or CLI help when available before writing exact commands.
Common 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 (./bundle-analyze-app.md) - Measure size impact
- bundle-native-assets.md (./bundle-native-assets.md) - Further size reduction
references/bundle-tree-shaking.md
title: Tree Shaking
impact: HIGH
tags: bundle, tree-shaking, dead-code, metro, repack
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 general tree shaking | Platform/dev-only shaking exists; use specialized tooling for unused exports |
| Expo SDK 52+ | Experimental unused import/export removal | Production only; requires ESM and side-effect-safe modules |
| Expo SDK 54+ | Verify defaults | Import support is documented as default; tree-shaking env toggles may still be required |
| Re.Pack | Yes | Via Webpack/Rspack optimizations and minification |
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
- Build production bundle (see bundle-analyze-js.md (./bundle-analyze-js.md))
- Analyze with source-map-explorer (see bundle-analyze-js.md (./bundle-analyze-js.md))
- Search for functions you know are unused
- 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 (./bundle-analyze-js.md) - Verify tree shaking effect
- bundle-barrel-exports.md (./bundle-barrel-exports.md) - Manual alternative
- bundle-code-splitting.md (./bundle-code-splitting.md) - Remote chunk loading safeguards
references/images/bundle-treemap-source-map-explorer.png
Binary file. Its content is not shown.
references/images/controlled-textinput-pingpong.png
Binary file. Its content is not shown.
references/images/devtools-flamegraph.png
Binary file. Its content is not shown.
references/images/emerge-xray-ios.png
Binary file. Its content is not shown.
references/images/expo-atlas-treemap.png
Binary file. Its content is not shown.
references/images/flashlight-flatlist-vs-flashlist.png
Binary file. Its content is not shown.
references/images/fps-drop-graph.png
Binary file. Its content is not shown.
references/images/memory-heap-snapshot.png
Binary file. Its content is not shown.
references/images/tti-warm-start-diagram.png
Binary file. Its content is not shown.
references/images/view-hierarchy-flattening.png
Binary file. Its content is not shown.
references/images/xcode-instruments-templates.png
Binary file. Its content is not shown.
references/images/xcode-thread-view.png
Binary file. Its content is not shown.
references/js-animations-reanimated.md
title: High-Performance Animations
impact: MEDIUM
tags: reanimated, animations, worklets, ui-thread
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';
import { Pressable } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
// Regular JS function
const trackAnalytics = (value) => {
analytics.track('animation_complete', { value });
};
const AnimatedComponent = () => {
const progress = useSharedValue(0);
const handlePress = () => {
progress.value = withTiming(1, { duration: 200 }, (finished) => {
if (finished) {
scheduleOnRN(trackAnalytics, 1);
}
});
};
const animatedStyle = useAnimatedStyle(() => {
return { opacity: progress.value };
});
return (
<Pressable onPress={handlePress}>
<Animated.View style={animatedStyle} />
</Pressable>
);
};Avoid calling scheduleOnRN from useAnimatedStyle; style worklets can evaluate more often than an analytics or state callback should run. Prefer animation completion callbacks or useAnimatedReaction.
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 |
For React Native Web targets, CSS transitions can be appropriate for simple state-driven style changes. In native apps, keep shared values/worklets for gesture-driven, scroll-driven, layout-sensitive, or orchestrated animations.
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
- Enable New Architecture - Reanimated 4 only supports Fabric + TurboModules
- Install
react-native-worklets- Required new dependency - Update Babel plugin - Change
'react-native-reanimated/plugin'to'react-native-worklets/plugin' - Update imports - Move worklet functions to
react-native-worklets - Update API calls - New functions take callback + args directly (not curried)
- Rebuild native apps - Required after adding
react-native-worklets
Related Skills
- js-measure-fps.md (./js-measure-fps.md) - Verify animation frame rate
- js-bottomsheet.md (./js-bottomsheet.md) - Keep bottom sheet visual state on the UI thread
- js-concurrent-react.md (./js-concurrent-react.md) - React-level deferral with useTransition
references/js-atomic-state.md
title: Atomic State Management
impact: HIGH
tags: state, jotai, zustand, re-renders, context
Skill: Atomic State Management
Use atomic state libraries (Jotai, Zustand) to reduce unnecessary re-renders without manual memoization.
Quick Pattern
Before (broad Context value):
const { filter, todos } = useContext(TodoContext);
// Re-renders when the provider value identity 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
Context is not inherently slow, but a broad provider value makes every consumer of that context eligible to re-render when the value identity changes. Atomic stores help when profiling shows unrelated subscribers rendering after global state updates.
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} />);
};Comparison
| Feature | Context | Jotai | Zustand |
|---|---|---|---|
| Re-render scope | Consumers of changed provider value | Atom subscribers | Selector subscribers |
| Derived state | Manual | Built-in atoms | Selectors |
| DevTools | React DevTools | Jotai DevTools | Zustand DevTools |
| Bundle size | 0 KB | Small dependency | Small dependency |
| Learning curve | Low | Medium | Low |
When to Use Which
Do not migrate global state solely for fewer re-renders if React Compiler or narrower subscriptions solve the measured issue. Atomic state helps when broad Context/store updates cause unrelated subscribers to render.
- 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 (./js-bottomsheet.md) - Avoid context-driven bottom sheet subtree re-renders
- js-react-compiler.md (./js-react-compiler.md) - Automatic memoization alternative
- js-profile-react.md (./js-profile-react.md) - Verify re-render reduction
references/js-bottomsheet.md
title: Bottom Sheet
impact: HIGH
tags: bottom-sheet, gorhom, re-renders, shared-values, gestures, context, scrollable, modal, keyboard
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: interpolate(
animatedIndex.value,
[0, 1],
[0, 0.5],
Extrapolation.CLAMP
),
}));
<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-sheetversioning / 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: interpolate(
animatedIndex.value,
[0, 1],
[0, 0.3],
Extrapolation.CLAMP
),
}));
<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(() => {
const progress = interpolate(
animatedIndex.value,
[threshold - 0.01, threshold],
[0, 1],
Extrapolation.CLAMP
);
return {
opacity: progress,
transform: [{ translateY: interpolate(progress, [0, 1], [50, 0]) }],
};
});
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 (./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 |
Prefer
BottomSheetTextInputinside 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) and needs a standard native-feeling sheet, evaluate @lodev09/react-native-true-sheet. Keep @gorhom/bottom-sheet when you need fine-grained Reanimated customization, custom gestures, or a mature cross-platform fallback.
| 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) |
npm install @lodev09/react-native-true-sheetCommon Pitfalls
- Using
onChangefor continuous position tracking — it fires on snap completion only (discrete). UseanimatedPositionoranimatedIndexshared values instead. - Starting timing animations inside sheet-index style worklets — derive gesture-linked visuals with
interpolate; reservewithTimingfor explicit state transitions. - 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 (./js-atomic-state.md) for splitting patterns.
- Assuming
enableDynamicSizingmust be disabled whenever you passsnapPoints— it does not have to be, but leaving it enabled can insert an additional snap point and change indexing. - Using React Native
ScrollView/FlatListinside bottom sheet — gestures won't coordinate. UseBottomSheetScrollView,BottomSheetFlatList, etc. - Gesture conflicts with React Native touchables — when touches do not respond inside the sheet, use the touchable components exported by
@gorhom/bottom-sheet, especially on Android. - Not providing
containerHeight— causes an extra re-render on mount for measurement. - Using a custom
TextInputwithout porting the library's focus/blur handlers — keyboard handling will be incomplete. PreferBottomSheetTextInputunless you need a custom input.
Related Skills
- js-animations-reanimated.md (./js-animations-reanimated.md) — SharedValue and useAnimatedStyle fundamentals
- js-atomic-state.md (./js-atomic-state.md) — Context splitting and atomic state patterns
- js-profile-react.md (./js-profile-react.md) — Profiling to measure re-render reduction
- js-measure-fps.md (./js-measure-fps.md) — Verify FPS improvement after optimization
references/js-concurrent-react.md
title: Concurrent React
impact: HIGH
tags: useDeferredValue, useTransition, suspense, concurrent
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 18+ features (
useDeferredValue,useTransition,Suspense) - React Native version that supports your target concurrent behavior; validate on the app architecture you ship
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
Use this only with a Suspense-enabled data source or framework integration. Wrapping arbitrary fetch() code in Suspense does not make it suspend automatically.
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
Wrap expensive components in
memo(): Without memoization, the component re-renders from parent anyway.Validate on your shipped architecture: Concurrent behavior depends on the React Native and React versions in the app.
Don't overuse: Only defer truly expensive work. Adding complexity for fast components is counterproductive.
Common Pitfalls
- Forgetting subtree isolation:
useDeferredValuehelps most when the expensive subtree is memoized or otherwise isolated from immediate parent re-renders - 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 (./js-profile-react.md) - Identify slow components
- js-react-compiler.md (./js-react-compiler.md) - Automatic memoization
- js-lists-flatlist-flashlist.md (./js-lists-flatlist-flashlist.md) - For list-specific optimizations
references/js-lists-flatlist-flashlist.md
title: Higher-Order Lists
impact: CRITICAL
tags: lists, flatlist, flashlist, legend-list, scrollview, virtualization
Skill: Higher-Order Lists
Replace ScrollView with FlatList, FlashList, or Legend List 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} />}
// FlashList v1 only: add estimatedItemSize.
// FlashList v2+: do not add estimated sizing props.
/>When to Use
- Rendering enough items that eager mounting affects FPS, memory, or startup
- List scrolling is choppy or laggy
- App freezes when loading list data
- Memory usage spikes with long lists
Prerequisites
@shopify/flash-listfor FlashList on React Native New Architecture@legendapp/listfor a JS/TypeScript list option without native dependencies- 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 (images/fps-drop-graph.png)
// BAD: ScrollView renders ALL items at once
const BadList = ({ items }) => (
<ScrollView>
{items.map((item) => (
<View key={item.id}>
<Text>{item.title}</Text>
</View>
))}
</ScrollView>
);Large eager lists mount every row immediately, increasing JS work, native view count, and memory before the user can interact.
2. Replace with FlatList
import { FlatList } from 'react-native';
const BetterList = ({ items }) => {
const renderItem = ({ item }) => (
<View>
<Text>{item.title}</Text>
</View>
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
);
};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.title}</Text>
</View>
);
const getItemLayout = (_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
});
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={getItemLayout}
/>
);
};4. Upgrade to FlashList
npm install @shopify/flash-listimport { FlashList } from '@shopify/flash-list';
const BestList = ({ items }) => {
const renderItem = ({ item }) => (
<View style={{ height: 50 }}>
<Text>{item.title}</Text>
</View>
);
return (
<FlashList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
);
};For FlashList v1, add estimatedItemSize with a realistic average item height. FlashList v2 requires React Native New Architecture and no longer needs size estimates; it computes sizing automatically. For old architecture apps, use FlashList v1 docs or evaluate Legend List.
FlashList advantages:
- Recycles views instead of creating new ones
- Often improves memory and scroll smoothness for large, complex lists
- Supports item-type-aware recycling with
getItemType
5. Evaluate Legend List
Legend List is a JS/TypeScript list alternative with no native dependency. It supports dynamic item sizes, bidirectional infinite scrolling, chat-friendly bottom alignment, and optional recycling.
Enable recycleItems for long lists after confirming item components do not keep item-specific local state or side effects.
Code Examples
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
/>Decision Matrix
| Scenario | Recommendation |
|---|---|
| Small static content | ScrollView OK |
| Measured eager-mount or scroll cost | FlatList minimum |
| Large or complex list | FlashList or Legend List |
| Complex item layouts | FlashList with getItemType, or Legend List |
| 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 (./js-profile-react.md) - Profile list rendering
- js-measure-fps.md (./js-measure-fps.md) - Measure scroll performance
references/js-measure-fps.md
title: Measure JS FPS
impact: HIGH
tags: fps, performance, monitoring, flashlight
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 visual output (FPS graphs, performance overlays). Use
agent-devicefor runnable scenario evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. FPS graph interpretation may still require exported reports or human review. Record concrete FPS ranges, dropped-frame counts, device tier, and build type in text when asking an agent to reason about them.
Step-by-Step Instructions
Method 1: React Perf Monitor (Quick Check)
Open Dev Menu:
- iOS Simulator:
Ctrl + Cmd + Zor Device > Shake - Android Emulator:
Cmd + M(Mac) /Ctrl + M(Windows)
- iOS Simulator:
Select "Perf Monitor"
Observe the overlay showing:
- UI (Main) thread FPS - Native rendering
- JS thread FPS - JavaScript execution
- RAM usage
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 (images/flashlight-flatlist-vs-flashlist.png)
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:
- Open Dev Menu
- Settings > JS Dev Mode → OFF
iOS (React Native CLI):
# Clear Metro cache if needed; this is not a production/release switch
npx react-native start --reset-cache
# Then run a Release scheme/build from Xcode or your CIExpo:
# 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 (./js-profile-react.md) - Find what's causing FPS drops
- js-animations-reanimated.md (./js-animations-reanimated.md) - Fix animation-related drops
- js-bottomsheet.md (./js-bottomsheet.md) - Measure bottom sheet gesture and snap performance
- js-lists-flatlist-flashlist.md (./js-lists-flatlist-flashlist.md) - Fix scroll-related drops
references/js-memory-leaks.md
title: Hunt JS Memory Leaks
impact: MEDIUM
tags: memory, leaks, profiling, cleanup
Skill: Hunt JS Memory Leaks
Find and fix JavaScript memory leaks using the React Native DevTools Memory tab, with agent-device react-devtools for related component context.
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 Memory tab or exported memory profile available
agent-device react-devtoolsfor related component ownership/render debugging- App running in development mode
Step-by-Step Instructions
React Native DevTools supports heap snapshots, allocation instrumentation on timeline, and allocation sampling. Use allocation timeline to isolate leaks; use allocation sampling for lower-overhead long-running allocation profiling. Use agent-device react-devtools when you need token-efficient component tree, props, state, hooks, ownership, or render-cause context while investigating the leak.
agent-device react-devtools does not replace the Memory tab. Use it only for related component context; heap snapshots and allocation timelines require the React Native DevTools Memory UI or an exported memory profile.
1. Open Memory Profiler
- Open the React Native DevTools Memory tab or load an exported memory profile
- Select "Allocation instrumentation on timeline"
2. Record Memory Allocations
- Click "Start" at the bottom
- Perform actions that might leak (navigate, trigger events, etc.)
- Wait 10-30 seconds
- 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 (images/memory-heap-snapshot.png)
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
- 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:
- Click on a blue spike in the timeline
- Look at the Constructor list below
- Check Shallow size vs Retained size
- Expand constructors to see individual allocations
- Click to see the exact source location
5. Verify the Fix
After fixing, re-profile the same flow. Memory should return to a stable baseline after GC and repeated interactions; some recent allocations can remain live legitimately.
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!
}, []);
};Other common sources are closures that retain large objects and module-level arrays/maps that only grow. Confirm these through retained-size paths before refactoring them.
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.
- Over-reading allocation colors: Persisting allocations are suspects, not proof. Confirm with retained objects and repeated flows.
- Missing useEffect cleanup: Most common React Native leak source.
Related Skills
- native-memory-leaks.md (./native-memory-leaks.md) - Native-side memory leaks
- js-profile-react.md (./js-profile-react.md) - General profiling
references/js-profile-react.md
title: Profile React Performance
impact: MEDIUM
tags: profiling, devtools, re-renders, flamegraph
Skill: Profile React Performance
Identify unnecessary re-renders and performance bottlenecks in React Native apps using React Native DevTools through agent-device react-devtools.
Quick Command
agent-device react-devtools status
agent-device react-devtools wait --connected
agent-device react-devtools profile start
agent-device react-devtools profile stop
agent-device react-devtools profile slow --limit 5
agent-device react-devtools profile rerenders --limit 5
agent-device react-devtools profile timeline --limit 20Drive the target interaction with normal agent-device commands between profile start and profile stop. For 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 connection available through
agent-device react-devtools - App running in development mode
- React DevTools version compatible with the app's React and React Native versions
- For release-build profiling,
@callstack/inspectorinstalled and connected first
Note: Prefer
agent-device react-devtoolsover the visual DevTools UI for token-efficient React profiling and debugging. Use the visual UI or exported profiler JSON only when the CLI output is insufficient. Record concrete commit times, render counts, and component names.
Manual fallback when agent-device is unavailable: open React Native DevTools from Metro (j) or the Dev Menu, use the Profiler tab, and record the same interaction. Keep this as fallback only; agent runs should prefer the CLI summaries above.
Step-by-Step Instructions
1. Connect React Native DevTools
agent-device react-devtools status
agent-device react-devtools wait --connectedIf status reports the helper is not running, start it first:
agent-device react-devtools start
agent-device react-devtools wait --connectedRelease Builds
React Native release builds do not expose the same profiling path by default. Before using agent-device react-devtools against a release app, wire in @callstack/inspector:
npm install @callstack/inspector
npx inspector startImport @callstack/inspector as the first module in the app entrypoint, wrap Metro config with withInspector(config, true), then build and run the app in release mode. For Expo, use a release build from prebuild/dev-client flow; Expo Go is not a release-build profiling target.
2. Record a Profiling Session
agent-device react-devtools profile start
agent-device react-devtools profile stopDrive the exact interaction or navigation flow under review between those two commands.
For AI-agent workflows, treat this as a required sequence:
- Run
agent-device react-devtools status. - Run
agent-device react-devtools wait --connected. - Start profiling immediately before the audited interaction.
- Drive the flow with normal
agent-devicecommands. - Stop profiling.
- Inspect slow components, re-render counts, and commit timing before proposing fixes.
3. Analyze Results
React DevTools Flamegraph (images/devtools-flamegraph.png)
Use bounded CLI summaries first:
agent-device react-devtools profile slow --limit 5
agent-device react-devtools profile rerenders --limit 5
agent-device react-devtools profile timeline --limit 20Then drill into a specific component:
agent-device react-devtools profile report @c5
agent-device react-devtools get component @c5Use the component ref printed by profile slow, profile rerenders, or get tree; @c5 is only an example.
Use the visual flame graph or exported profiler JSON only when the bounded CLI summaries do not answer the question.
4. Profile JavaScript CPU
For non-React CPU issues, use platform CPU profilers or agent-device perf instead of React DevTools render profiling.
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
- Using one build type for every question: Use
agent-device react-devtoolsin development to identify render causes, commit patterns, and expensive components. Validate timing-sensitive FPS/CPU improvements in production or release-like builds. - 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 (./js-react-compiler.md) - Automatic memoization
- js-atomic-state.md (./js-atomic-state.md) - Reduce re-renders with Jotai/Zustand
- js-bottomsheet.md (./js-bottomsheet.md) - Profile bottom sheet callback-driven re-renders
- js-measure-fps.md (./js-measure-fps.md) - Quantify frame rate impact
references/js-react-compiler.md
title: React Compiler
impact: HIGH
tags: memoization, react-compiler, memo, useMemo, useCallback
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
- Babel-based build system
- Code follows Rules of React
- Check current React Native, Expo, and React Compiler release notes before copying version-specific setup
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
Use Expo's SDK-specific path:
# SDK 54 and later: Babel is auto-configured
npx expo install babel-plugin-react-compiler@beta
# SDK 53: install runtime too
npx expo install babel-plugin-react-compiler@beta react-compiler-runtime@betaThen enable the experiment in app config:
{
"expo": {
"experiments": {
"reactCompiler": true
}
}
}React Native without Expo
npm install -D babel-plugin-react-compiler@latestFor React 17 or 18 targets, also install the compiler runtime:
npm install react-compiler-runtime@latestPrefer the setup path documented for the app's exact Expo SDK, React Native, and React versions.
Step 3: Configure Babel (React Native without Expo)
For non-Expo React Native projects, configure Babel manually and keep the compiler first in the plugin pipeline:
// 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],
// ... other plugins
],
};
};Step 4: Set Up ESLint (Recommended)
Use the React Hooks/Compiler lint rules that match the app's React version. For Expo, SDK 55+ includes React Compiler lint rules through eslint-config-expo; SDK 54 and earlier need eslint-plugin-react-compiler. Fix rule violations before treating a component as compiler-optimized; skipped components are safe but do not get the intended memoization.
Step 5: Verify Optimizations
Verify with agent-device react-devtools before/after render measurements. For release-build verification, connect @callstack/inspector first so React DevTools can attach. Some visual DevTools versions show compiler memoization badges, but profiler evidence is the stable signal.
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, restart Metro with a cleared cache.
Strategy 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.
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
Expect the largest wins in components that currently rely on manual memoization discipline or have cascading re-renders. Already well-memoized code may show little change; keep the compiler only when profiling or maintenance cost justifies it.
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 (./js-profile-react.md) - Verify optimization impact
- js-atomic-state.md (./js-atomic-state.md) - Alternative for state-related re-renders
references/js-uncontrolled-components.md
title: Uncontrolled Components
impact: HIGH
tags: textinput, forms, controlled, uncontrolled
Skill: Uncontrolled Components
Fix TextInput synchronization and flickering issues by using the uncontrolled component pattern where React does not need to own every keystroke.
Quick Pattern
Before (controlled - may flicker on legacy arch):
<TextInput value={text} onChangeText={setText} />After (uncontrolled - native owns state):
<TextInput defaultValue={text} onChangeText={setText} />When to Use
- TextInput flickers or shows wrong characters during fast typing
- Text input lags behind user input on low-end devices
- Using legacy (non-New Architecture) React Native
- Need maximum input responsiveness
- React does not need to transform, mask, validate, or own the value on every keystroke
Prerequisites
- Understanding of React controlled vs uncontrolled components
- TextInput component in use
Problem Description
Controlled TextInput Ping-Pong Communication (images/controlled-textinput-pingpong.png)
The diagram shows what happens when typing "TEST" with a controlled TextInput:
- User types "T" →
onChangeText('T')fires - React calls
setValue('T')→ native updates to "T" - User types "E" →
onChangeText('TE')fires - React calls
setValue('TE')→ native updates to "TE" - ...continues for each character
The problem: Each character requires a round-trip between native and JavaScript. On legacy architecture, if React state update is slow, native may show intermediate states (flicker).
New Architecture note: This issue is largely resolved in New Architecture, but uncontrolled pattern still provides best performance.
Use uncontrolled TextInput primarily as a responsiveness or legacy-architecture escape hatch. Keep controlled inputs when React must transform, mask, validate, or own the value on every keystroke.
Step-by-Step Instructions
1. Identify Controlled TextInput
// Controlled - value prop syncs state to native
const ControlledInput = () => {
const [value, setValue] = useState('');
return (
<TextInput
value={value} // This causes sync issues
onChangeText={setValue}
/>
);
};2. Convert to Uncontrolled
Remove the value prop to make it uncontrolled:
// Uncontrolled - native owns the state
const UncontrolledInput = () => {
const [value, setValue] = useState('');
return (
<TextInput
defaultValue={value} // Only sets initial value
onChangeText={setValue} // Still updates React state
/>
);
};3. Use Ref for Programmatic Control
If you need to read/set value programmatically:
const UncontrolledWithRef = () => {
const inputRef = useRef(null);
const clearInput = () => {
inputRef.current?.clear();
};
const getValue = () => {
// Use onChangeText to track value, or native methods
};
return (
<TextInput
ref={inputRef}
defaultValue=""
onChangeText={(text) => console.log('Current:', text)}
/>
);
};Code Examples
Full Migration Example
Before (Controlled):
const SearchInput = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (text) => {
setQuery(text);
fetchResults(text).then(setResults);
};
return (
<View>
<TextInput
value={query} // Remove this
onChangeText={handleChange}
placeholder="Search..."
/>
<ResultsList data={results} />
</View>
);
};After (Uncontrolled):
const SearchInput = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = (text) => {
setQuery(text);
fetchResults(text).then(setResults);
};
return (
<View>
<TextInput
defaultValue="" // Initial value only
onChangeText={handleChange}
placeholder="Search..."
/>
<ResultsList data={results} />
</View>
);
};When You Need Value Control
For input masking or validation that modifies input:
// Option 1: Accept the controlled behavior (may flicker)
const MaskedInput = () => {
const [value, setValue] = useState('');
const handleChange = (text) => {
// Phone mask: (123) 456-7890
const masked = maskPhone(text);
setValue(masked);
};
return (
<TextInput
value={value} // Necessary for masking
onChangeText={handleChange}
/>
);
};
// Option 2: Use a native masked input library
// react-native-masked-text handles this nativelyDecision Matrix
| Scenario | Recommendation |
|---|---|
| Simple text input | Uncontrolled |
| Search/filter input | Uncontrolled |
| Form with validation on submit | Uncontrolled |
| Input masking (phone, credit card) | Controlled or native library |
| Character-by-character validation | Controlled |
| New Architecture app | Either works well |
Common Pitfalls
- Forgetting
defaultValue: Without it, input starts empty - Trying to clear with state: Use
ref.current.clear()instead - Mixing patterns: Don't use both
valueanddefaultValue
Related Skills
- js-profile-react.md (./js-profile-react.md) - Profile input performance
- js-concurrent-react.md (./js-concurrent-react.md) - Defer expensive search operations
references/native-android-16kb-alignment.md
title: Android 16 KB Page Size Alignment
impact: CRITICAL
tags: android, native, 16kb, alignment, page-size, google-play, third-party
Android 16 KB page size alignment
Quick Reference
| Item | Details |
|---|---|
| Google Play requirement | Apps and updates targeting Android 15+ must support 16 KB page sizes on 64-bit devices |
| React Native support | RN 0.79+ includes aligned RN-provided native binaries; still verify third-party .so files |
| What to check | Third-party native libraries (.so files) |
| Official documentation | developer.android.com/guide/practices/page-sizes |
Quick Command
Verify generated APK alignment using Android's official zipalign tool:
zipalign -c -P 16 -v 4 app-release.apkIf any 64-bit libraries (arm64-v8a, x86_64) show misalignment, they need updating.
For deeper ELF-level inspection, use Android's check_elf_alignment.sh script.
When to Check
React Native 0.79+ builds core binaries with correct alignment. However, third-party native libraries may still be misaligned. Check alignment when:
- Adding or updating SDKs with native code
- Preparing a release for Google Play
- Investigating crashes on Android 15+ devices with 16 KB page size
CI Integration
Add alignment checks to your release pipeline after producing release APKs. If you ship AABs, generate device APKs with your normal release tooling or bundletool, then run zipalign on those APKs:
zipalign -c -P 16 -v 4 app-release.apk 2>&1 | tee alignment.log
if grep -q "Verification FAILED" alignment.log; then exit 1; fiStep-by-Step
- Build your release artifact
- Generate or locate the release APK(s)
- Run
zipalignverification (see Quick Command) - If misaligned libraries are found, trace them to source packages (see below)
- Update, replace, or remove the affected dependencies
For runtime testing, use the 16KB Android Emulator image or enable "Boot with 16KB page size" on Pixel 8/8a/9 devices.
Tracing Misaligned Libraries
When zipalign reports a misaligned library like libfoo.so, find its source package:
# Find the .so file in node_modules
find node_modules -name "libfoo.so" 2>/dev/null
# Or search gradle files for references
grep -r "foo" node_modules/*/android --include="*.gradle" 2>/dev/nullOnce identified, update the dependency or contact the vendor for a 16KB-compatible build.
Common Pitfalls
- Waiting for Play Store rejection instead of checking in CI
- Assuming a React Native upgrade rebuilds third-party native binaries
- Only checking 32-bit ABIs (
armeabi-v7a,x86) — these are not affected - Using
zipalignwithout the-P 16flag (checks 4 KB, not 16 KB) - Validating only debug builds
Fixing Alignment Issues
Alignment issues require rebuilding the native library with a compatible toolchain. Repackaging alone does not fix them.
See official remediation steps for detailed guidance.
Related Skills
- native-profiling.md (./native-profiling.md) — Native debugging tools
references/native-measure-tti.md
title: Measure TTI (Time to Interactive)
impact: HIGH
tags: tti, startup, performance, markers
Skill: Measure TTI (Time to Interactive)
Set up performance markers to measure app startup time and track TTI improvements.
Quick Command
npm install react-native-performance// Mark when screen is interactive
import performance from 'react-native-performance';
useEffect(() => {
performance.mark('screenInteractive');
}, []);When to Use
- App startup feels slow
- Need baseline metrics for optimization
- Setting up performance monitoring
- Comparing TTI across releases
Prerequisites
react-native-performancelibrary (recommended)
Note: This skill involves visual timeline diagrams and profiler output. Use
agent-devicefor cold-start evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. Timeline interpretation may still require exported metrics or human review. Record concrete marker names, durations, device tier, and startup type in text when asking an agent to reason about them.
Understanding TTI
Time to Interactive: Time from app icon tap to displaying usable content.
Startup Types
| Type | Description | Measure? |
|---|---|---|
| Cold | App not in memory, full init | ✅ Yes |
| Warm | Process exists, activity recreated | ❌ Skip |
| Hot | App in background, resumed | ❌ Skip |
| Prewarmed (iOS) | iOS pre-initialized app | ❌ Filter out |
Only measure cold starts for consistent metrics.
React Native Startup Pipeline
Pipeline markers:
1. Native Process Init (nativeLaunchStart → nativeLaunchEnd)
2. Native App Init (appCreationStart → appCreationEnd)
3. JS Bundle Load (runJSBundleStart → runJSBundleEnd)
4. RN Root View Render (contentAppeared)
5. React App Interactive (screenInteractive) ← This is TTIStep-by-Step Implementation
1. Detect Cold Start
iOS (Swift):
let isColdStart = ProcessInfo.processInfo.environment["ActivePrewarm"] != "1"Android (Kotlin):
class MainApplication : Application() {
var isColdStart = false
override fun onCreate() {
super.onCreate()
var firstPostEnqueued = true
Handler().post { firstPostEnqueued = false }
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
unregisterActivityLifecycleCallbacks(this)
if (firstPostEnqueued && savedInstanceState == null) {
isColdStart = true
}
}
// ... other callbacks
})
}
}2. Check Foreground State
Only measure when app starts in foreground.
iOS:
var isForegroundProcess = false
override func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if application.applicationState == .active {
isForegroundProcess = true
}
return true
}Android:
private fun isForegroundProcess(): Boolean {
val processInfo = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(processInfo)
return processInfo.importance == IMPORTANCE_FOREGROUND
}3. Set Up Performance Markers
Using react-native-performance:
Native (iOS):
import ReactNativePerformance
RNPerformance.sharedInstance().mark("appCreationStart")
// ... app init ...
RNPerformance.sharedInstance().mark("appCreationEnd")Native (Android):
import com.oblador.performance.RNPerformance
RNPerformance.getInstance().mark("appCreationStart")
// ... app init ...
RNPerformance.getInstance().mark("appCreationEnd")4. Mark Screen Interactive (JavaScript)
import performance from 'react-native-performance';
export default function HomeScreen() {
useEffect(() => {
// Mark when meaningful content is displayed
performance.mark('screenInteractive');
}, []);
return <TabNavigator />;
}5. Collect and Report Metrics
import performance from 'react-native-performance';
const collectTTIMetrics = () => {
const entries = performance.getEntriesByType('mark');
// Calculate durations
const metrics = {
nativeInit: getMarkDuration('nativeLaunchStart', 'nativeLaunchEnd'),
appCreation: getMarkDuration('appCreationStart', 'appCreationEnd'),
jsBundleLoad: getMarkDuration('runJSBundleStart', 'runJSBundleEnd'),
tti: getMarkDuration('nativeLaunchStart', 'screenInteractive'),
};
// Send to analytics
analytics.track('app_performance', metrics);
};Built-in Markers
react-native-performance provides automatic markers:
| Marker | Description |
|---|---|
nativeLaunchStart |
Process start (pre-main) |
nativeLaunchEnd |
Native init complete |
runJSBundleStart |
JS bundle loading starts |
runJSBundleEnd |
JS bundle loaded |
contentAppeared |
RN root view rendered |
nativeLaunchStart is pre-main and may include iOS prewarming. For prewarm-sensitive analysis, add a custom marker in main() and compare it with nativeLaunchStart.
Listening to Native Events
Use the native marker APIs exposed by the app's React Native version to record JS bundle load and content-appeared milestones. Keep marker names consistent across iOS, Android, analytics, and test scripts.
Target Metrics
Treat 2-4s as a broad external heuristic, not a universal target. Define app-specific targets by device tier, startup path, release build, and user/product metrics; optimize only against cold-start measurements filtered for warm, hot, prewarmed, and background launches.
Common Pitfalls
- Including prewarmed starts: iOS prewarming skews metrics
- Measuring warm/hot starts: Only cold starts are meaningful
- Wrong screenInteractive placement: Mark when truly interactive, not just mounted
- Not filtering background launches: Push notifications can start app in background
Related Skills
- bundle-analyze-js.md (./bundle-analyze-js.md) - Reduce JS bundle load time
- native-profiling.md (./native-profiling.md) - Profile native init
- bundle-hermes-mmap.md (./bundle-hermes-mmap.md) - Improve Android TTI
references/native-memory-leaks.md
title: Hunt Native Memory Leaks
impact: MEDIUM
tags: memory, leaks, xcode, instruments, profiler
Skill: Hunt Native Memory Leaks
Find native memory leaks using Xcode Leaks and Android Studio Memory Profiler.
Quick Command
# iOS: Profile with Leaks instrument
# Xcode → Product → Profile (Cmd+I) → Leaks template
# Android: Memory Profiler
# Android Studio → Run → Profile → Track Memory ConsumptionWhen to Use
- App memory grows despite JS profiler showing no leaks
- Native modules suspected of leaking
- Activity recreation causes memory growth (Android)
- C++/Swift/Kotlin code under investigation
iOS: Xcode Leaks
Quick Check: Memory Report
- Run app via Xcode
- Open Debug Navigator (side panel)
- Click Memory
- Watch graph for continuous growth
Deep Analysis: Instruments Leaks
Xcode Instruments Templates (images/xcode-instruments-templates.png)
- Xcode → Product → Profile (or Cmd+I)
- Select Leaks template (highlighted with orange triangle icon in the grid)
- Click Choose
- Click Record (red circle)
- Use the app, perform suspect actions
- Stop recording
Analyzing Results
Red markers = Leaked memory detected
Click on leak to see:
- Leaked Object: Type and size
- Responsible Library: Which code leaked
- Responsible Frame: Exact function
- Stack Trace: Full call path (right panel)
Open the responsible frame to jump to source when symbols are available.
Common Native Leak: Missing Ownership
void createNewStrings() {
auto str = std::make_unique<std::string>("Hello");
}Prefer RAII/smart pointers over raw new/delete in native module code.
Android: Memory Profiler
Launch Profiler
Use Android Studio Memory Profiler and select a memory-consumption recording for the target process.
Recording
- Start the app
- Perform actions that might leak
- Watch memory graph for growth patterns
Analyzing Allocations
Memory profiler shows:
- Allocations count: Objects created
- Deallocations count: Objects freed
- Live objects: Still in memory
If allocations greatly exceed deallocations after GC and after repeating the same flow, suspect a leak; confirm via retained objects, references, and lifecycle expectations.
LeakCanary (Android JVM Leaks)
Use LeakCanary as a debug-only first line of defense for Android JVM leaks such as retained Activity/Context references, listeners, and coroutines that outlive a module. It does not see JS heap leaks or JSI/C++ Turbo Module leaks and can report React Native framework false positives.
Common Android Leak: Listener Not Removed
// BAD: Leaks MainActivity on config change
class MainActivity : AppCompatActivity(), Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EventManager.addListener(this)
// Never removed!
}
}
// GOOD: Remove listener
class MainActivity : AppCompatActivity(), Callback {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EventManager.addListener(this)
}
override fun onDestroy() {
EventManager.removeListener(this)
super.onDestroy()
}
}Activity Recreation Test
Repeat navigation and configuration-change flows, then check whether old activities or module instances are retained after GC.
React Native note: RN opts out via android:configChanges in manifest, but native code might not.
Use agent-device to repeat rotation/navigation scenarios, capture snapshots/screenshots, and collect device evidence. If it is missing and device verification is needed, install it through the environment's approved/trusted path or ask the user to install or enable it. Read the agent-device skill or CLI help when available before writing exact commands.
Debugging Workflow
iOS
- Profile with Instruments Leaks
- Trigger suspect actions repeatedly
- Wait for red leak markers
- Click to identify responsible frame
- Fix and re-test
Android
- Profile memory consumption
- Trigger suspect actions (rotate, navigate)
- Check allocation/deallocation counts
- Look for classes with 0 deallocations
- Fix and re-test
Code Fixes by Pattern
Reference Cycle (Swift)
// BAD
class Parent {
var child: Child?
}
class Child {
var parent: Parent? // Strong reference cycle
}
// GOOD
class Parent {
var child: Child?
}
class Child {
weak var parent: Parent? // Weak breaks cycle
}Missing Cleanup (C++)
// BAD
void process() {
auto* data = new LargeData();
if (error) return; // Leak!
delete data;
}
// GOOD: RAII with unique_ptr
void process() {
auto data = std::make_unique<LargeData>();
if (error) return; // Automatically cleaned up
}Verification
After fixing:
- Re-run profiler
- Perform same actions
- Verify:
- iOS: No red leak markers
- Android: Allocations return to a stable baseline after GC and repeated flows
Common Pitfalls
- Testing in debug mode: Some leaks only appear in release
- Not waiting for GC: Force GC before concluding no leak
- Ignoring small leaks: They add up over time
- Missing cleanup in invalidate(): Turbo Modules need proper cleanup
Related Skills
- native-memory-patterns.md (./native-memory-patterns.md) - Understanding memory patterns
- js-memory-leaks.md (./js-memory-leaks.md) - JS-side leaks
- native-threading-model.md (./native-threading-model.md) - Module invalidation
references/native-memory-patterns.md
title: Native Memory Management
impact: MEDIUM
tags: memory, c++, swift, kotlin, arc, smart-pointers
Skill: Native Memory Management
Understand memory management patterns in C++, Swift, and Kotlin for React Native native modules.
Quick Reference
| Pattern | Languages | Mechanism |
|---|---|---|
| Reference Counting | Swift, Obj-C | Count refs, free at zero |
| Smart pointers | C++ | Ownership encoded in pointer type |
| Garbage Collection | Kotlin/Java, JavaScript | GC scans and frees unreachable |
| Manual | C, C++ (raw pointers) | Explicit new/delete |
Key rule: Prefer stack allocation or std::unique_ptr for single ownership; use std::shared_ptr only for real shared ownership and std::weak_ptr to break cycles. In Swift, use weak when the referenced object can disappear first; use unowned only when its lifetime is guaranteed to be at least as long.
When to Use
- Writing native modules with manual memory management
- Debugging native memory leaks
- Interfacing C++ with Swift/Kotlin
- Understanding reference counting vs garbage collection
C++ Smart Pointers
std::unique_ptr - Single Owner
#include <memory>
void takeOwnership(std::unique_ptr<std::string> s) {
std::cout << *s;
// Automatically deleted when function ends
}
int main() {
auto str = std::make_unique<std::string>("Hello");
// Can only be moved, not copied
takeOwnership(std::move(str));
// str is now empty
return 0;
}std::shared_ptr - Multiple Owners
void useShared(std::shared_ptr<std::string> s) {
std::cout << *s; // Reference count temporarily +1
}
void useReference(const std::shared_ptr<std::string>& s) {
std::cout << *s; // No ref count change (passed by reference)
}
int main() {
auto str = std::make_shared<std::string>("Hello");
useShared(str); // Copies pointer, ref count +1
useReference(str); // No copy, ref count unchanged
std::cout << *str; // Still valid
return 0;
}std::weak_ptr - Non-Owning Reference
void useWeak(std::weak_ptr<std::string> weak) {
if (auto shared = weak.lock()) { // Check if still exists
std::cout << *shared;
} else {
std::cout << "Object destroyed";
}
}
int main() {
auto str = std::make_shared<std::string>("Hello");
std::weak_ptr<std::string> weak = str; // No ref count increase
useWeak(weak); // Works
str.reset(); // Destroys object
useWeak(weak); // "Object destroyed"
return 0;
}Swift ARC (Automatic Reference Counting)
class Person {
let name: String
init(name: String) { self.name = name }
deinit { print("Deallocated") }
}
do {
let person1 = Person(name: "John") // Ref count: 1
do {
let person2 = person1 // Ref count: 2
} // person2 out of scope, ref count: 1
} // person1 out of scope, ref count: 0, "Deallocated"Breaking Reference Cycles with weak
// BAD: Reference cycle (memory leak)
class A {
var b: B?
}
class B {
var a: A? // Strong reference creates cycle
}
// GOOD: Use weak to break cycle
class A {
var b: B?
}
class B {
weak var a: A? // Weak reference, doesn't prevent deallocation
}Kotlin/Android GC
Weak References for Caches
WeakHashMap weakens keys, not values. Store WeakReference values explicitly when the cached value itself should not be strongly retained. Do not rely on deterministic System.gc() behavior in tests.
WeakReference for Callbacks
class DataManager {
// Weak references to listeners prevent memory leaks
private val listeners = mutableListOf<WeakReference<DataListener>>()
fun addListener(listener: DataListener) {
listeners.add(WeakReference(listener))
}
fun notifyListeners(data: String) {
listeners.forEach { ref ->
ref.get()?.onDataChanged(data)
}
}
}Common Memory Leak Sources
1. Forgetting to Delete (C++)
// BAD: Memory leak
int main() {
std::string* str = new std::string("Hello");
// Forgot to delete!
return 0;
}
// GOOD: Use smart pointers or stack allocation
int main() {
auto str = std::make_unique<std::string>("Hello");
// Automatically deleted
return 0;
}2. Reference Cycles (Swift/C++)
// BAD: Cycle
class A { std::shared_ptr<B> b; };
class B { std::shared_ptr<A> a; };
// GOOD: Break with weak_ptr
class A { std::shared_ptr<B> b; };
class B { std::weak_ptr<A> a; };3. Unremoved Listeners (Kotlin)
// BAD: Listener never removed
class MyClass {
private val listener = object : Callback {
override fun onEvent() { /* ... */ }
}
init {
EventManager.addListener(listener)
// Never removed!
}
}
// GOOD: Implement cleanup
class MyClass : AutoCloseable {
private val listener = object : Callback {
override fun onEvent() { /* ... */ }
}
init {
EventManager.addListener(listener)
}
override fun close() {
EventManager.removeListener(listener)
}
}Swift Unmanaged (Advanced)
Use Unmanaged only for C interop that explicitly transfers ownership. Match passRetained with takeRetainedValue, and passUnretained with takeUnretainedValue.
Best Practices Summary
| Language | Best Practice |
|---|---|
| C++ | Prefer stack or unique_ptr; use shared_ptr only for shared ownership |
| Swift | Use weak for delegates and disappearing references; use unowned only with guaranteed lifetime |
| Kotlin | Implement AutoCloseable, use WeakReference |
| All | Prefer stack over heap when possible |
Related Skills
- native-memory-leaks.md (./native-memory-leaks.md) - Find leaks with profilers
- native-turbo-modules.md (./native-turbo-modules.md) - Build memory-safe modules
references/native-platform-setup.md
title: Platform Differences
impact: MEDIUM
tags: ios, android, xcode, gradle, cocoapods
Skill: Platform Differences
Navigate iOS and Android tooling, dependency management, and build systems in React Native.
Quick Reference
| Platform | IDE | Package Manager | Build System |
|---|---|---|---|
| JavaScript | VS Code | npm/yarn/pnpm/bun | Metro |
| iOS | Xcode | CocoaPods | xcodebuild |
| Android | Android Studio | Gradle | Gradle |
# Common commands
bundle install # Install ruby bundler
cd ios && bundle exec pod install # Install CocoaPods deps
cd android && ./gradlew tasks # Verify Gradle wrapper and tasks
xed ios/ # Open XcodeWhen to Use
- Setting up native development environment
- Adding native dependencies
- Debugging platform-specific issues
- Understanding build processes
Dependency Management
React Native autolinking only handles libraries structured as React Native modules. React Native packages often ship native iOS/Android code through npm, then CocoaPods/Gradle reference local files from node_modules. Pure native dependencies still need Podfile or Gradle changes.
JavaScript (npm/yarn/pnpm/bun)
Infer package manager from lockfile: package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb.
iOS (CocoaPods)
# Install pods after npm install
bundle install
cd ios && bundle exec pod install
# Key files
ios/Podfile # Pod dependencies
ios/Pods/ # Installed pods (gitignored)
ios/*.xcworkspace # Open this in Xcode (not .xcodeproj)
Gemfile # Ruby/CocoaPods versionAndroid (Gradle)
# Verify Gradle after adding dependencies
cd android && ./gradlew tasks
# Key files
android/build.gradle # Project-level config
android/app/build.gradle # App dependencies
android/gradle.properties # Build flags
android/gradlew # Gradle wrapperUse exact Gradle dependency versions in production. Avoid dynamic + versions because they can change builds unpredictably.
Common Commands
# iOS
bundle install # Install ruby bundler
cd ios && bundle exec pod install # Install pods
xcrun simctl list # List simulators
# Android
cd android && ./gradlew clean # Clean build
./gradlew tasks # List available tasks
./gradlew assembleRelease # Build release APK
# React Native CLI
npx react-native start # Start Metro
npx react-native run-ios # Run on iOS
npx react-native run-android # Run on Android
# Expo
npx expo start # Start Metro (Expo)
npx expo run:ios # Run on iOS (dev client)
npx expo run:android # Run on Android (dev client)
npx expo prebuild # Generate native projectsTroubleshooting
| Issue | Solution |
|---|---|
| Pod install fails | cd ios && bundle exec pod install --repo-update |
| Xcode build fails | cd ios && xcodebuild clean |
| Android Gradle sync fails | Open Android Studio sync details, then run the failing Gradle task directly |
| Can't find simulator | xcrun simctl list to verify name |
| Metro cache issues | npx react-native start --reset-cache |
| React Native cache issues | Clear the specific cache reported by the failing tool |
Related Skills
- native-profiling.md (./native-profiling.md) - Use IDE profilers
- native-turbo-modules.md (./native-turbo-modules.md) - Build native modules
- upgrading-react-native.md (../../upgrading-react-native/references/upgrading-react-native.md) - Upgrade React Native safely
references/native-profiling.md
title: Profile Native Code
impact: MEDIUM
tags: xcode, instruments, android-studio, profiler
Skill: Profile Native Code
Use Xcode Instruments and Android Studio Profiler to identify native performance bottlenecks.
Quick Command
# iOS: Open Instruments
# Xcode → Open Developer Tool → Instruments → Time Profiler
# Android: Open Profiler
# Android Studio → View → Tool Windows → ProfilerWhen to Use
- App is slow but JS profiler shows no issues
- Investigating native module performance
- Startup feels slow (native init)
- Battery drain concerns
- Need CPU/memory breakdown by thread
Note: This skill involves visual profiler output (Xcode Instruments, Android Studio Profiler). Use
agent-devicefor runnable app evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. Profiler-specific GUI analysis may still require exported traces or human review. Record concrete thread names, stack frames, and durations in text when asking an agent to reason about them.
iOS Profiling with Xcode
Quick Check: Debug Navigator
Use Xcode's Debug Navigator for quick CPU, memory, disk, and network signals before collecting a full Instruments trace.
CPU percentage can exceed 100% (multi-core usage).
Deep Profiling: Instruments
Record a Time Profiler trace on the target device, perform the suspect interaction, and inspect the relevant threads and call stacks.
Analyzing Time Profiler Results
Key views:
- Flame Graph: Visual call stack over time
- Call Tree: Hierarchical function breakdown
- Ranked: Functions sorted by time (Bottom-Up)
Useful filters:
- Hide System Libraries
- Invert Call Tree (bottom-up view)
- Filter by thread (main, JS, etc.)
Identifying problems:
- Microhang: Brief UI unresponsiveness
- Hang: Full UI thread block (critical)
- Yellow = most time spent
Thread Breakdown
Pin threads to compare:
- Main thread (SampleApp): UI rendering
- JavaScript thread: React/JS execution
- Background threads: Native modules
JS thread blocking and UI thread blocking are different signals; inspect both before choosing a fix.
Android Profiling with Android Studio
Launch Profiler
Use Android Studio Profiler on the target device or emulator.
CPU Profiling
Record CPU hotspots while performing the suspect interaction, then inspect flame graph, bottom-up, and timeline views.
Analyzing Results
Flame Graph:
- Zoom with scroll/pinch
- Click to expand call stacks
- Filter by keyword (e.g., "hermes")
Views:
- Top Down: From entry points down
- Bottom Up: From slowest functions up
- Flame Chart: Timeline visualization
Reading the Call Stack
Example analysis:
JS Thread activity after button press:
- Event handler on main thread
- Triggers JS work via sync JSI calls
- Hermes processes React reconciliation
- Significant time in commit/layout-related workPlatform Tools Summary
| Tool | Platform | Use Case |
|---|---|---|
| Time Profiler | iOS | CPU hotspots |
| Leaks | iOS | Memory leaks |
| Hangs | iOS | UI thread blocks |
| CPU Profiler | Android | CPU hotspots |
| Memory Profiler | Android | Memory tracking |
| Perfetto | Android | Advanced trace analysis |
Perfetto (Advanced Android)
Export traces from Android Studio and analyze at ui.perfetto.dev:
- Cross-process analysis
- Custom trace events
- Additional visualizations
Expo Notes
- Expo Go: Cannot profile native code directly; JS profiling only
- Dev Client / Prebuild: Full native profiling supported via Xcode/Android Studio
- Run
npx expo prebuildto generate native projects, then profile as bare React Native
Common Findings
| Symptom | Likely Cause |
|---|---|
| Main thread hangs | Heavy UI work, blocked operations |
| JS thread spikes | React re-renders, heavy computation |
| Background thread busy | Native module work |
| Memory climbing | Leak (see memory profiling skills) |
Related Skills
- native-measure-tti.md (./native-measure-tti.md) - Profile startup specifically
- native-memory-leaks.md (./native-memory-leaks.md) - Memory profiling
- js-profile-react.md (./js-profile-react.md) - JS/React profiling
references/native-sdks-over-polyfills.md
title: Native SDKs
impact: HIGH
tags: polyfills, intl, crypto, navigation, native
Skill: Native SDKs
Replace web polyfills and JS navigators with native React Native implementations for better performance.
Quick Pattern
Before (JS polyfills - 430+ KB):
import '@formatjs/intl-datetimeformat/polyfill';
import CryptoJS from 'crypto-js';
import { createStackNavigator } from '@react-navigation/stack';After (native implementations):
// Keep this polyfill only if the app uses DateTimeFormat options/locales
// unsupported by the target Hermes/platform combination.
import { createHash } from 'react-native-quick-crypto';
import { createNativeStackNavigator } from '@react-navigation/native-stack';When to Use
- Large JS bundle from polyfills
- Navigation feels non-native
- Crypto operations are slow
- Internationalization bloating bundle
Step-by-Step Instructions
1. Remove Unnecessary Intl Polyfills
Hermes supports many Intl APIs natively, but not every constructor and method combination across platforms. Audit the exact APIs and methods you use before removing polyfills:
// BEFORE: All these polyfills (430+ KB)
import '@formatjs/intl-getcanonicallocales/polyfill';
import '@formatjs/intl-locale/polyfill';
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/en';
import '@formatjs/intl-datetimeformat/polyfill';
import '@formatjs/intl-datetimeformat/locale-data/en';
import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/en';
import '@formatjs/intl-relativetimeformat/polyfill';
import '@formatjs/intl-relativetimeformat/locale-data/en';
import '@formatjs/intl-displaynames/polyfill';Hermes Intl support must be checked against the Hermes version in the app:
| API | Hermes | Keep Polyfill? |
|---|---|---|
Intl.Collator |
✅ | No |
Intl.DateTimeFormat |
⚠️ Partial | Maybe |
Intl.NumberFormat |
⚠️ Partial | Maybe |
Intl.getCanonicalLocales() |
✅ | No |
Intl.supportedValuesOf() |
✅ | No |
Intl.Locale |
❌ | Yes |
Intl.PluralRules |
❌ | Yes |
Intl.RelativeTimeFormat |
❌ | Yes |
Intl.DisplayNames |
❌ | Yes |
Intl.ListFormat |
❌ | Yes |
Intl.Segmenter |
❌ | Yes |
Constructor support does not guarantee every option or method your app uses. Keep polyfills for any API, option, locale data, or method the app depends on but Hermes does not fully support on the target platform.
// AFTER: Keep only the polyfills your app still needs
import '@formatjs/intl-locale/polyfill';
import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/en';
import '@formatjs/intl-relativetimeformat/polyfill';
import '@formatjs/intl-relativetimeformat/locale-data/en';
import '@formatjs/intl-displaynames/polyfill';If you use Intl.NumberFormat.prototype.formatToParts() on Hermes/iOS, also keep:
import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/en';2. Use Native Crypto
Replace JS crypto with native C++ implementation:
npm install react-native-quick-crypto// BEFORE: Slow JS implementation
import CryptoJS from 'crypto-js';
// AFTER: Native C++ implementation
import { createHash } from 'react-native-quick-crypto';Essential for:
- Web3 wallet seed generation
- CSPRNG (Cryptographically Secure Random Numbers)
- Any heavy cryptographic operations
Benchmark crypto changes on the target device class. Native implementations usually reduce JS-thread work, but the exact win depends on algorithm, payload size, and bridge/JSI overhead.
3. Use Native Stack Navigator
npm install @react-navigation/native-stack react-native-screens// BEFORE: JS-based stack (more flexible, less native)
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
// AFTER: Native stack (native feel, better performance)
import { createNativeStackNavigator } from '@react-navigation/native-stack';
const Stack = createNativeStackNavigator();
// Usage is nearly identical
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>Benefits:
- Native navigation animations
- Platform-specific headers (large titles on iOS)
- Lower memory usage
- Offloads work from JS thread
4. Use Native Bottom Tabs
npm install @bottom-tabs/react-navigation react-native-bottom-tabs// BEFORE: JS tabs
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Tabs = createBottomTabNavigator();
// AFTER: Native tabs
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
const Tabs = createNativeBottomTabNavigator();
<Tabs.Navigator>
<Tabs.Screen name="Home" component={HomeScreen} />
<Tabs.Screen name="Profile" component={ProfileScreen} />
</Tabs.Navigator>Recommended Native Libraries
| Category | Library | Description |
|---|---|---|
| Navigation | react-native-screens |
Native screen containers |
| Menus | zeego |
Native menus (Radix-like API) |
| Slider | @react-native-community/slider |
Native slider |
| Date Picker | react-native-date-picker |
Native date/time picker |
Decision Matrix
| Scenario | Use Native? | Tradeoff |
|---|---|---|
| Standard navigation | ✅ Yes | Slight API differences |
| Custom transition animations | ⚠️ Maybe | Native is more limited |
| Platform-consistent UI | ✅ Yes | Less customization |
| Unique/branded design | ⚠️ Consider JS | Native may not support |
Common Pitfalls
- Assuming constructor support means full method coverage: Check the specific Hermes API and methods you call
- Ignoring migration effort: Native navigators have slightly different APIs
- Over-customizing native components: If design requires heavy customization, JS might be better
Related Skills
- bundle-analyze-js.md (./bundle-analyze-js.md) - Measure polyfill impact
- bundle-library-size.md (./bundle-library-size.md) - Compare library sizes
references/native-threading-model.md
title: Threading Model
impact: HIGH
tags: threads, turbo-modules, fabric, async, sync
Skill: Threading Model
Understand which threads Turbo Modules and Fabric use for initialization, method calls, and view updates.
Quick Reference
Thread names and exact scheduling can vary by React Native version, architecture, and host app setup. Use this as a default mental model, then confirm with a profiler when the exact thread matters.
| Action | Default assumption |
|---|---|
| UI view creation/updates | Main/UI thread |
| Sync value-returning Turbo Module method | Blocks the JS caller until it returns |
| Async Turbo Module method | Does not block JS, but may run on a shared native modules executor |
| Heavy CPU/I/O work | Move to a module-owned background queue/coroutine |
Key rule: Sync methods should be trivial and deterministic. Move anything that can block, allocate heavily, perform I/O, or wait on locks to async/background work.
When to Use
- Building native modules
- Debugging threading issues
- Accessing UI from native code
- Understanding async vs sync method behavior
Available Threads
| Thread | Name in Debugger | Purpose |
|---|---|---|
| Main/UI | Main thread | UI rendering, UIKit/Android Views |
| JavaScript | mqt_v_js |
JS execution, React |
| Native Modules | mqt_v_native |
Async Turbo Module calls |
| Custom | Various | Your background threads |
Turbo Modules Threading
Initialization
| Platform | Thread | Notes |
|---|---|---|
| iOS | Main thread | Assumes UIKit access needed |
| Android (lazy) | JS thread | Default behavior |
| Android (eager) | Native modules thread | When needsEagerInit = true |
iOS: React Native runs init on main thread assuming UIKit access.
Android Eager Loading:
// ReactModuleInfo constructor params:
// canOverrideExistingModule, needsEagerInit, isCxxModule, isTurboModule
ReactModuleInfo(
AwesomeModule.NAME,
AwesomeModule.NAME,
false,
true, // needsEagerInit = true → runs on native modules thread
false,
true
)Synchronous Method Calls
Synchronous value-returning Turbo Module methods block the JS caller until they return. Treat them as JS-critical even if a platform implementation dispatches through an internal executor.
// iOS - runs on JS thread
@objc func multiply(_ a: Double, b: Double) -> NSNumber {
// This blocks JS for entire duration!
return a * b as NSNumber
}Danger: Long sync operations freeze the app:
// BAD: Blocks JS for 20 seconds
@objc func multiply(_ a: Double, b: Double) -> NSNumber {
Thread.sleep(forTimeInterval: 20) // App frozen!
return a * b as NSNumber
}Asynchronous Method Calls
Usually dispatched off the JS thread - does not block JS while the native work is pending.
The native modules thread is shared across modules. If async work is CPU-heavy or long-running, move it to a module-owned queue/coroutine scope rather than occupying the shared React Native native modules thread.
// iOS - runs on mqt_v_native thread
@objc func asyncOperation(
_ a: Double,
resolve: @escaping RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
// Already on background thread
resolve(a * 2)
}// Android - runs on native modules thread
override fun asyncOperation(a: Double, promise: Promise?) {
// Already on background thread
promise?.resolve(a * 2)
}Module Invalidation
Called when React Native instance is torn down (e.g., Metro reload):
| Platform | Thread |
|---|---|
| iOS | Native modules thread |
| Android | ReactHost thread pool |
iOS: Implement RCTInvalidating protocol.
Fabric (Native Views) Threading
View Lifecycle
| Operation | Default assumption |
|---|---|
| View init | Main thread |
| Prop updates | Main thread |
| Layout/shadow tree work | Architecture-dependent; profile before assuming thread ownership |
Views always manipulate UI on main thread (UIKit/Android requirement).
Do not use a hard-coded "Yoga runs on X thread" rule when diagnosing performance. React Native's renderer and scheduler details change across New Architecture releases; use Instruments, Perfetto, or Android Studio profiler to identify the actual bottleneck.
Moving Work to Background
iOS: DispatchQueue
@objc func heavyWork(
resolve: @escaping RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
DispatchQueue.global().async {
// Heavy computation here
let result = self.compute()
resolve(result)
}
}Android: Coroutines
class MyModule(reactContext: ReactApplicationContext) :
NativeMyModuleSpec(reactContext) {
private val moduleScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun heavyWork(promise: Promise?) {
moduleScope.launch {
// Heavy computation here
val result = compute()
promise?.resolve(result)
}
}
override fun invalidate() {
super.invalidate()
moduleScope.cancel() // Important: cancel to prevent leaks
}
}Thread Safety Checklist
| Scenario | Safe? | Solution |
|---|---|---|
| Sync method accessing shared state | ⚠️ | Use locks/synchronized |
| Async method accessing UI | ❌ | Dispatch to main thread |
| Multiple async calls to same resource | ⚠️ | Queue or mutex |
| Accessing JS from background | ❌ | Use CallInvoker |
Accessing UI from Background (iOS)
DispatchQueue.global().async {
let result = self.heavyComputation()
DispatchQueue.main.async {
// Safe to update UI here
self.updateUI(with: result)
}
}Accessing UI from Background (Android)
moduleScope.launch(Dispatchers.Default) {
val result = heavyComputation()
withContext(Dispatchers.Main) {
// Safe to update UI here
updateUI(result)
}
}Summary Table
| Action | iOS Thread | Android Thread |
|---|---|---|
| Module init | Version/setup dependent; avoid blocking | Version/setup dependent; avoid blocking |
| Sync method | Blocks JS caller | Blocks JS caller |
| Async method | Shared native executor or implementation-defined | Shared native executor or implementation-defined |
| View init | Main | Main |
| Prop update | Main | Main |
| Yoga/layout | Profile; do not assume fixed ownership | Profile; do not assume fixed ownership |
| Invalidate | Native modules | ReactHost pool |
Related Skills
- native-turbo-modules.md (./native-turbo-modules.md) - Implement background threads
- native-profiling.md (./native-profiling.md) - Debug thread issues
references/native-turbo-modules.md
title: Fast Native Modules
impact: HIGH
tags: turbo-modules, native, swift, kotlin, c++
Skill: Fast Native Modules
Build performant Turbo Modules using modern languages and background threading.
Quick Pattern
Incorrect (sync method blocks JS thread):
@objc func heavyWork() -> NSNumber {
Thread.sleep(forTimeInterval: 2) // Blocks JS for 2s!
return 42
}Correct (async on background thread):
@objc func heavyWork(
resolve: @escaping RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
DispatchQueue.global().async {
let result = self.compute()
resolve(result)
}
}When to Use
- Creating new native modules
- Optimizing existing module performance
- Heavy computation needs to run off JS thread
- Cross-platform C++ code needed
Prerequisites
- React Native Builder Bob for scaffolding
npx create-react-native-library@latest my-libraryStep-by-Step Instructions
1. Scaffold with Builder Bob
npx create-react-native-library@latest awesome-library
# Follow prompts: choose Turbo Module, select languagesCreates ready-to-publish library with:
- iOS (Obj-C/Swift) support
- Android (Kotlin) support
- TypeScript definitions
- Codegen setup
For local modules:
npx create-react-native-library@latest awesome-library --local2. Run on Background Thread (iOS)
@objc func heavyOperation(
_ input: Double,
resolve: @escaping RCTPromiseResolveBlock,
reject: RCTPromiseRejectBlock
) {
DispatchQueue.global().async {
// Heavy work on background thread
let result = self.expensiveComputation(input)
resolve(result)
}
}3. Run on Background Thread (Android)
class AwesomeLibraryModule(reactContext: ReactApplicationContext) :
NativeAwesomeLibrarySpec(reactContext) {
private val moduleScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun heavyOperation(input: Double, promise: Promise?) {
moduleScope.launch {
// Heavy work on coroutine
val result = expensiveComputation(input)
promise?.resolve(result)
}
}
override fun invalidate() {
super.invalidate()
moduleScope.cancel() // Prevent memory leaks!
}
}Use structured concurrency: keep a module-owned CoroutineScope, cancel it in invalidate(), avoid GlobalScope.launch, use SupervisorJob so one failed operation does not cancel unrelated in-flight work, and choose Dispatchers.Default for CPU work or Dispatchers.IO for disk/network/database work.
4. Use C++ for Cross-Platform Code
Create C++ Turbo Module for shared logic:
// MyCppModule.h
#pragma once
#include <ReactCommon/TurboModule.h>
namespace facebook::react {
class MyCppModule : public TurboModule {
public:
MyCppModule(std::shared_ptr<CallInvoker> jsInvoker);
double multiply(double a, double b);
};
} // namespace facebook::reactFollow the registration mechanism documented for the React Native version you target. Avoid copying old +load registration workarounds unless current RN docs or template output still require them.
Threading Summary
| Method Type | Default Thread | Best Practice |
|---|---|---|
| Sync | JS thread | Keep fast (<16ms) |
| Async | Native modules thread | OK for moderate work |
| Heavy async | Custom background | Use DispatchQueue/Coroutines |
Language Interop Costs
| Interface | Overhead | Notes |
|---|---|---|
| Obj-C / Obj-C++ ↔ C++ | Low | Common iOS interop path |
| Swift ↔ C++ | Version-dependent | Verify supported Swift/Xcode/RN setup |
| Kotlin ↔ C++ (JNI) | Higher | Batch calls and avoid per-item crossings |
| C++ Turbo Module | Low | JSI direct access when correctly registered |
Tip: C++ Turbo Modules skip JNI at runtime since JS holds direct C++ function references via JSI.
Code Example: Complete Async Operation
// TypeScript interface
export interface Spec extends TurboModule {
multiply(a: number, b: number): number; // Sync
heavyOperation(input: number): Promise<number>; // Async
}// Android implementation
override fun heavyOperation(input: Double, promise: Promise?) {
moduleScope.launch {
try {
val result = withContext(Dispatchers.Default) {
// Simulate heavy work
delay(1000)
input * 2
}
promise?.resolve(result)
} catch (e: Exception) {
promise?.reject("ERROR", e.message)
}
}
}// iOS implementation
@objc func heavyOperation(
_ input: Double,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
DispatchQueue.global(qos: .userInitiated).async {
// Simulate heavy work
Thread.sleep(forTimeInterval: 1.0)
let result = input * 2
resolve(result)
}
}Common Pitfalls
- Sync methods that block: Keep sync methods trivial and deterministic; make anything that can block, allocate heavily, perform I/O, or wait on locks async/background work
- Forgetting to cancel coroutine scope: Causes memory leaks
- Not handling errors in async: Always try/catch with reject
- Accessing UI from background: Dispatch to main thread
Related Skills
- native-threading-model.md (./native-threading-model.md) - Thread details
- native-memory-patterns.md (./native-memory-patterns.md) - Memory in native code
references/native-view-flattening.md
title: View Flattening
impact: MEDIUM
tags: views, flattening, collapsable, hierarchy
Skill: View Flattening
Understand and debug React Native's view flattening optimization.
Quick Pattern
Problem (children get flattened unexpectedly):
<NativeTabBar>
<Tab1 /> // May be flattened, breaking native component
<Tab2 />
</NativeTabBar>Solution (prevent flattening):
<NativeTabBar>
<Tab1 collapsable={false} />
<Tab2 collapsable={false} />
</NativeTabBar>When to Use
- Native component receives unexpected number of children
- Layout debugging with native components
- Building native components that accept children
- Understanding React Native rendering
Note: This skill involves visual view hierarchy tools (Xcode Debug View Hierarchy, Android Layout Inspector). Use
agent-devicefor screen evidence; install it through the environment's approved/trusted path or ask the user if verification needs it and it is missing. Native hierarchy inspection may still require Xcode, Android Studio, or human review. Record native child counts and component names in text when asking an agent to reason about them.
What is View Flattening?
React Native's renderer automatically removes "layout-only" views that:
- Only affect layout (no visual rendering)
- Don't need to exist in native view hierarchy
Benefits: Reduced memory, faster rendering, shallower view tree.
The Problem with Native Components
// You expect 3 children
<MyNativeComponent>
<Child1 />
<Child2 />
<Child3 />
</MyNativeComponent>If a child wrapper is flattened, native code may receive a different child count or shape than the JS tree suggests.
Preventing Flattening with collapsable
<MyNativeComponent>
<Child1 collapsable={false} />
<Child2 collapsable={false} />
<Child3 collapsable={false} />
</MyNativeComponent>The direct views marked collapsable={false} are preserved as native children.
Debugging View Hierarchy
View Hierarchy Flattening (images/view-hierarchy-flattening.png)
Use native debugging tools to see the actual view hierarchy:
Xcode (iOS)
- Run app via Xcode
- Click "Debug View Hierarchy" in debug toolbar (shown in image)
- Inspect 3D view of native hierarchy
Component class names vary by architecture and React Native version; verify the actual native hierarchy in the tool.
Android Studio
- Run app via Android Studio
- View → Tool Windows → Layout Inspector
- Select running process
Component class names vary by architecture and React Native version; verify the actual native hierarchy in the tool.
Code Examples
When Flattening Breaks Your Component
// Your native component expects exactly 2 tabs
const NativeTabBar = requireNativeComponent('RCTTabBar');
// BAD: TabContent might get flattened
const MyTabs = () => (
<NativeTabBar>
<TabContent title="Home">
<View><Text>Home content</Text></View>
</TabContent>
<TabContent title="Profile">
<View><Text>Profile content</Text></View>
</TabContent>
</NativeTabBar>
);
// GOOD: Prevent flattening
const MyTabs = () => (
<NativeTabBar>
<TabContent title="Home" collapsable={false}>
<View><Text>Home content</Text></View>
</TabContent>
<TabContent title="Profile" collapsable={false}>
<View><Text>Profile content</Text></View>
</TabContent>
</NativeTabBar>
);Wrapper Component with collapsable
// Wrapper that prevents flattening
const NativeChildWrapper = ({ children, ...props }) => (
<View collapsable={false} {...props}>
{children}
</View>
);
// Usage
<NativeComponent>
<NativeChildWrapper>
<ComplexChild />
</NativeChildWrapper>
</NativeComponent>When Views Get Flattened
React Native can flatten layout-only wrappers that do not need their own native view for drawing, events, accessibility, measurement, or native-component child semantics. The exact rules vary across renderer versions.
Forcing a View to Stay
Use collapsable={false} as the stable fix. Style or handler changes can be useful as debugging probes, but do not keep them as the production solution:
// Diagnostic probes only
<View style={{ backgroundColor: 'transparent' }} />
<View style={{ borderWidth: 0.01 }} />
<View style={{ opacity: 0.99 }} />
<View onLayout={() => {}} />Remove these probes after confirming flattening is the issue.
Debugging Checklist
- Check native child count: Log received children in native code
- Use Layout Inspector: Visual hierarchy debugging
- Add collapsable={false}: Test if flattening is the issue
- Check wrapper components: Intermediate views may be flattened
Common Pitfalls
- Assuming JS children = native children: Flattening changes this
- Not documenting native component requirements: If your native component expects specific child count, document it
- Over-using collapsable={false}: Only use when necessary (loses optimization benefits)
Related Skills
- native-platform-setup.md (./native-platform-setup.md) - IDE setup for debugging
- native-profiling.md (./native-profiling.md) - Performance impact analysis
Frontmatter written into each target's SKILL.md.
Common
No fields set for this target.