Advanced
This advanced tutorial covers expert React Native topics through 29 heavily annotated examples. Each example maintains 1-2.25 comment lines per code line.
Prerequisites
Before starting, complete Beginner and Intermediate examples or ensure you understand:
- React Native New Architecture (JSI, Fabric, TurboModules, Codegen)
- Reanimated 4 shared values and worklets
- Expo Router navigation and EAS fundamentals
- TypeScript generics, discriminated unions, and type inference
Group 16: Native Modules
Example 57: Nitro Modules — Custom Native Module
Nitro Modules (react-native-nitro-modules 0.35.4) are statically compiled JSI bindings — faster than TurboModules by avoiding dynamic class registration.
// TypeScript spec (NitroSpecs/HybridMath.nitro.ts)
import { HybridObject } from "react-native-nitro-modules";
// => HybridObject: base type for Nitro Module specifications
export interface HybridMath extends HybridObject<{ ios: "swift"; android: "kotlin" }> {
// => HybridObject<{ ios: 'swift'; android: 'kotlin' }>:
// => tells Nitro to generate Swift bindings for iOS, Kotlin for Android
add(a: number, b: number): number;
// => synchronous: Nitro Modules can be synchronous (unlike async TurboModules)
fibonacci(n: number): number;
// => complex computation: runs on native thread without serialization
}// ios/HybridMath.swift
import Foundation
import NitroModules
class HybridMath: HybridMathSpec {
// => HybridMathSpec: generated by Nitro Codegen from TypeScript spec
var hybridContext = margelo.nitro.HybridContext()
// => hybridContext: required boilerplate for Nitro lifecycle management
func add(a: Double, b: Double) -> Double {
// => a, b: TypeScript number → Swift Double (automatic type mapping)
return a + b // => synchronous return: no async/promise
}
func fibonacci(n: Double) -> Double {
// => Fibonacci: CPU-intensive, benefits from native execution
guard n > 1 else { return n }
var a: Double = 0, b: Double = 1
for _ in 2...Int(n) { (a, b) = (b, a + b) }
return b
}
}// JavaScript usage (after Nitro Codegen runs at build time)
import { NitroModules } from "react-native-nitro-modules";
const math = NitroModules.createHybridObject<HybridMath>("HybridMath");
// => createHybridObject: creates JS proxy backed by native Nitro object
// => direct C++ memory reference — zero-copy, zero-serialization
const sum = math.add(3, 4); // => 7 (synchronous!)
const fib = math.fibonacci(40); // => 102334155 (synchronous!)
// => Without Nitro: fibonacci(40) in JS takes ~1500ms
// => With Nitro (native): fibonacci(40) takes ~1ms
console.log(`3 + 4 = ${sum}, fib(40) = ${fib}`);Key Takeaway: Define the module spec as a TypeScript interface extending HybridObject. Implement in Swift/Kotlin. Use synchronous returns for computation-heavy operations that would block JS. Nitro's static compilation makes it faster than TurboModules' dynamic class registration.
Why It Matters: Nitro Modules represent the cutting edge of React Native native interop. The statically compiled bindings eliminate the runtime lookup overhead present in TurboModules — critical for hot paths like real-time sensor processing, cryptographic operations, or image manipulation that get called thousands of times per second. VisionCamera V5 and react-native-mmkv V4 both use Nitro Modules for this reason. Understanding the TypeScript spec → Codegen → Swift/Kotlin pattern is the foundation for any serious native module development in 2026.
Example 58: TurboModules + Codegen — The Standard Native Module Path
TurboModules with Codegen is the official React Native native module API — more widely documented than Nitro, with full Expo Modules API support as a higher-level alternative.
// Native module TypeScript spec (NativeCalcModule.ts)
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";
export interface Spec extends TurboModule {
// => Spec: extends TurboModule (required by Codegen)
multiply(a: number, b: number): Promise<number>;
// => Promise<number>: TurboModules are typically async (unlike Nitro)
getDeviceInfo(): Promise<{
// => returning object: Codegen generates native struct types
model: string;
systemVersion: string;
totalMemory: number;
}>;
}
export default TurboModuleRegistry.getEnforcing<Spec>("NativeCalcModule");
// => getEnforcing: throws if module not found (vs get() returns null)
// => 'NativeCalcModule': must match the name registered in native code// package.json codegenConfig (required for Codegen to process the spec)
{
"codegenConfig": {
"name": "RNCalcModuleSpec",
"type": "modules",
"jsSrcsDir": "src/specs",
"android": { "javaPackageName": "com.myapp.calc" }
}
}
// => Codegen runs at build time: generates C++ glue, Java/Kotlin wrappers, ObjC++ wrappers
// => Build step: npx react-native codegen (or auto in EAS Build)
// Usage in React Native component
import CalcModule from "./NativeCalcModule";
async function performCalculations() {
const result = await CalcModule.multiply(6, 7);
// => await: TurboModules are async by default
// => result: 42 (native multiplication, returned as Promise)
const info = await CalcModule.getDeviceInfo();
// => info: { model: 'iPhone 16 Pro', systemVersion: '18.4', totalMemory: 8192 }
console.log(`${info.model} on iOS ${info.systemVersion}, ${info.totalMemory}MB RAM`);
}Key Takeaway: Define the TypeScript Spec extending TurboModule. Configure codegenConfig in package.json. Codegen generates type-safe native binding code at build time. Use TurboModuleRegistry.getEnforcing() for non-optional modules.
Why It Matters: Codegen eliminates the runtime type mismatch errors that plagued the legacy bridge — the TypeScript spec and native implementation are validated at build time. If the TypeScript spec says multiply(a: number, b: number) returns Promise<number> but the Swift implementation returns String, the build fails with a clear error rather than a runtime crash. This compile-time safety is why the New Architecture is a significant quality improvement for native module development, particularly for teams where JS and native developers work in parallel.
Example 59: Fabric Custom Native View — Nitro View Spec
Custom native views (components rendered by native UI widgets) use Fabric's component spec system via Nitro's View spec.
// Component TypeScript spec (NitroSpecs/HybridMapView.nitro.ts)
import type { ViewProps } from "react-native";
import { HybridObject } from "react-native-nitro-modules";
// => ViewSpec: for native view components (not just modules)
export interface HybridMapViewProps extends ViewProps {
// => extends ViewProps: inherits standard View props (style, testID, etc.)
latitude: number;
longitude: number;
zoom?: number; // => optional prop with ?
onRegionChange?: (event: {
// => event callback prop
latitude: number;
longitude: number;
}) => void;
}// ios/HybridMapView.swift
import UIKit
import NitroModules
import MapKit
@objc class HybridMapView: UIView, HybridMapViewSpec {
// => HybridMapViewSpec: generated from TypeScript spec
// => UIView subclass: all Fabric custom views extend UIView on iOS
private var mapView: MKMapView!
// => MKMapView: iOS native MapKit view component
@objc var latitude: Double = 0 { didSet { updateRegion() } }
// => @objc required for Fabric property observation
// => didSet: synchronous update when JS changes the prop
@objc var longitude: Double = 0 { didSet { updateRegion() } }
@objc var zoom: Double = 10 { didSet { updateRegion() } }
@objc var onRegionChange: RCTDirectEventBlock?
// => RCTDirectEventBlock: type for JS callback events from native view
override init(frame: CGRect) {
super.init(frame: frame)
mapView = MKMapView(frame: bounds)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(mapView)
// => embed native MapKit view as child of this custom Fabric view
}
private func updateRegion() {
let region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
latitudinalMeters: 1000 * (20 - zoom),
longitudinalMeters: 1000 * (20 - zoom)
)
mapView.setRegion(region, animated: true)
}
}// Usage in React Native
import { HybridMapView } from 'your-map-nitro-module';
function MapScreen() {
return (
<HybridMapView
style={{ flex: 1 }}
latitude={-6.2146} // => Jakarta latitude
longitude={106.8451} // => Jakarta longitude
zoom={12}
onRegionChange={(event) => {
console.log('Map moved to:', event.latitude, event.longitude);
}}
/>
);
}Key Takeaway: Custom Fabric views extend HybridObject as a ViewSpec. Native UIView/ViewGroup subclasses receive props as @objc properties with didSet observers. Event callbacks use RCTDirectEventBlock on iOS.
Why It Matters: Custom native views are the mechanism for embedding any platform-native UI widget in React Native — maps, charts, video players, AR overlays. Before Fabric, custom views required complex RCTViewManager and UIView lifecycle management. Nitro's ViewSpec reduces this to a TypeScript interface + property setters. The synchronous prop update via didSet enables high-frequency prop changes (like real-time map position updates from sensor data) without the async bridge round-trip delay that limited the legacy architecture.
Group 17: Camera and Graphics
Example 60: VisionCamera V5 — Constraints API
VisionCamera 5.0.4 is a Nitro Module camera library. V5 replaces the old Formats API with the new Constraints API — a breaking change from V4.
import { Camera, useCameraDevice, useCameraFormat } from 'react-native-vision-camera';
// => react-native-vision-camera 5.0.4: Nitro Module rewrite
// => BREAKING: V5 Constraints API replaces V4 Formats API entirely
export default function VisionCameraDemo() {
const device = useCameraDevice('back');
// => useCameraDevice: selects the physical camera device
// => 'back' | 'front' | 'external'
// => device: CameraDevice | undefined (undefined while loading or no camera)
const format = useCameraFormat(device, [
// => useCameraFormat: Constraints API (V5) — replaces V4's manual format selection
// => V4: you selected format from device.formats array manually
// => V5: you declare constraints, library selects best matching format
{ videoAspectRatio: 16 / 9 }, // => constraint: prefer 16:9 video
{ videoResolution: { width: 1920, height: 1080 } }, // => prefer 1080p
// => constraints evaluated in priority order (first = highest priority)
{ fps: 60 }, // => prefer 60fps
{ photoHdr: true }, // => prefer HDR photos
{ videoStabilizationMode: 'cinematic-extended' }, // => prefer best stabilization
// => if device doesn't support 60fps HDR at 1080p,
// => library finds next-best format matching the most constraints
]);
// => format: CameraFormat | undefined — the resolved native format
if (!device || !format) {
return null; // => device/format not yet available
}
return (
<Camera
style={{ flex: 1 }}
device={device}
isActive={true} // => isActive: starts camera preview when true
format={format} // => apply resolved format
photo={true} // => enable photo output
video={false} // => disable video output (saves memory)
audio={false} // => no audio
enableHighQualityPhotos={true}
// => enableHighQualityPhotos: uses highest resolution photo capture pipeline
/>
);
}
// => V4 vs V5 migration example:
// V4 (OLD — do NOT use in V5):
// const formats = device.formats;
// const format = formats.sort((a, b) => b.videoWidth - a.videoWidth)[0];
// => Manual format selection: fragile, device-specific
// V5 (NEW — use this):
// const format = useCameraFormat(device, [{ videoResolution: 'max' }]);
// => Declarative constraints: library handles format selection across all devicesKey Takeaway: V5's Constraints API replaces V4's manual device.formats selection. Declare what you want (fps, resolution, hdr, stabilization) as a priority-ordered list; the library finds the best matching format. isActive controls the preview pipeline.
Why It Matters: VisionCamera V5's Nitro Module rewrite achieves ~2GB/s throughput on the JSI bridge — fast enough to process uncompressed camera frames in JavaScript/C++ worklets in real time. The Constraints API solves a major V4 pain point: manually selecting camera formats required device-specific testing since format arrays differ dramatically between iPhone models and Android devices. The declarative constraints approach works correctly across all devices and OS versions, reducing camera-related crash reports by abstracting the format selection complexity.
Example 61: VisionCamera Frame Processors
Frame processors run custom logic on every camera frame using worklets — enabling real-time computer vision without leaving the UI thread.
import { Camera, useCameraDevice, useFrameProcessor } from 'react-native-vision-camera';
import { useSharedValue } from 'react-native-reanimated';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
export default function FrameProcessorDemo() {
const device = useCameraDevice('back');
const detectedColor = useSharedValue<string>('#000');
// => sharedValue: shared between frame processor (UI thread) and animated styles
const frameProcessor = useFrameProcessor((frame) => {
'worklet';
// => 'worklet' directive: this function runs on the Camera frame thread
// => frame: Frame object — contains raw pixel data
const width = frame.width; // => frame pixel width (e.g. 1920)
const height = frame.height; // => frame pixel height (e.g. 1080)
// => Sample center pixel color (demonstration)
// => In production: use a native frame processor plugin for heavy operations
// => e.g. @mrousavy/vision-camera-resize-plugin for resizing before ML inference
const centerX = Math.floor(width / 2);
const centerY = Math.floor(height / 2);
// => frame.toArrayBuffer(): converts frame to raw pixel buffer
// => Used with ML models (TFLite, Core ML) for inference
// => 2GB/s JSI throughput: fast enough for 4K frame analysis
// => Communicate result back to Reanimated shared value
detectedColor.value = '#0173B2'; // => update shared value from worklet
// => This triggers useAnimatedStyle update on UI thread (no React re-render)
}, []);
// => useFrameProcessor: creates a worklet that runs on every camera frame
// => dependency array: re-creates worklet if deps change
const overlayStyle = useAnimatedStyle(() => ({
borderColor: detectedColor.value,
// => border color changes when frame processor detects color
}));
if (!device) return null;
return (
<Camera
style={{ flex: 1 }}
device={device}
isActive={true}
frameProcessor={frameProcessor} // => attach frame processor to camera
// => Every frame (16-33ms at 30-60fps) triggers frameProcessor worklet
pixelFormat="yuv"
// => pixelFormat: 'yuv' (YCbCr, most efficient) | 'rgb' | 'native'
// => 'yuv' preferred: native camera format, no color space conversion cost
/>
);
}Key Takeaway: useFrameProcessor creates a worklet running on the camera thread for every frame. Write to Reanimated shared values to update UI without React re-renders. Use pixelFormat="yuv" for maximum frame throughput.
Why It Matters: Frame processors enable real-time computer vision in a React Native app — barcode scanning (100fps), face detection, document scanning, AR overlays, and ML inference. The worklet architecture means frame analysis runs at camera frame rate (30-120fps) on a dedicated thread, completely independent of React rendering. This throughput is impossible with the legacy bridge (which serialized pixel data to Base64, losing 99% of bandwidth). Production use cases: document scanner apps (Camcard), QR scanners, fitness apps with pose detection (from TFLite models), and live filter apps.
Example 62: React Native Skia — Canvas, Path, Paint
@shopify/react-native-skia 2.6.2 provides GPU-accelerated 2D graphics via the Skia rendering engine used in Flutter and Chrome.
import { Canvas, Path, Paint, Circle, Group, LinearGradient, vec } from '@shopify/react-native-skia';
// => @shopify/react-native-skia 2.6.2: GPU-accelerated graphics on New Architecture
import { View, StyleSheet } from 'react-native';
export default function SkiaDemo() {
const width = 300;
const height = 200;
return (
<View style={styles.container}>
<Canvas style={{ width, height }}>
{/* => Canvas: Skia rendering context — all drawing inside */}
{/* => Group: transform group (translate, rotate, scale) */}
<Group>
{/* => Circle: filled/stroked circle */}
<Circle cx={60} cy={60} r={40}>
<Paint color="#0173B2" />
{/* => Paint: fill color */}
</Circle>
{/* => Circle with stroke only */}
<Circle cx={60} cy={60} r={40}>
<Paint color="#DE8F05" style="stroke" strokeWidth={3} />
{/* => style: 'fill' (default) | 'stroke' */}
</Circle>
</Group>
{/* => Path: arbitrary vector paths (SVG-like) */}
<Path
path="M 150 20 L 200 80 L 100 80 Z"
// => SVG path: MoveTo, LineTo, LineTo, ClosePath = triangle
>
<LinearGradient
start={vec(100, 20)} // => gradient start point
end={vec(200, 80)} // => gradient end point
colors={['#029E73', '#CC78BC']} // => accessible palette colors
// => gradient fills the path
/>
</Path>
{/* => Complex path: arc + quadratic Bezier */}
<Path
path={`M 50 150 Q 150 100 250 150`}
// => Q: quadratic Bezier curve (control point 150,100)
>
<Paint color="#CA9161" style="stroke" strokeWidth={4} strokeCap="round" />
{/* => strokeCap: 'butt' | 'round' | 'square' */}
</Path>
</Canvas>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
});Key Takeaway: Draw inside <Canvas>. Use <Circle>, <Path>, <Group> with nested <Paint> or gradient children. Skia renders on the GPU — completely off the React render tree, enabling 120fps drawing without React reconciliation.
Why It Matters: React Native Skia enables chart libraries (Victory Native, Skia Charts), custom UI widgets, signature capture, drawing apps, and data visualizations that require GPU-level performance. The Skia engine renders directly to a Metal (iOS) or Vulkan (Android) surface, bypassing React's Virtual DOM entirely. This is why Shopify uses Skia for their Flash List item decorations and product image carousels — the GPU handles the rendering at hardware frame rate. Custom shaders (GLSL) enable blur effects, color grading, and particle systems that React Native's standard view system cannot achieve.
Example 63: Skia + Reanimated — Animating Canvas Values
Combine Reanimated shared values with Skia's useDerivedValue to animate canvas drawing properties at UI thread speed.
import { Canvas, Circle, useDerivedValue } from '@shopify/react-native-skia';
import { useSharedValue, withRepeat, withTiming, withSequence } from 'react-native-reanimated';
import { View, StyleSheet } from 'react-native';
import { useEffect } from 'react';
export default function SkiaAnimatedDemo() {
const progress = useSharedValue(0);
// => Reanimated shared value: lives on UI thread
useEffect(() => {
progress.value = withRepeat(
withSequence(
withTiming(1, { duration: 1200 }), // => expand over 1.2s
withTiming(0, { duration: 1200 }), // => collapse over 1.2s
),
-1, // => -1 = repeat indefinitely
false // => false = don't reverse (sequence handles it)
);
}, []);
const radius = useDerivedValue(() => {
// => useDerivedValue: Skia-specific hook, creates derived value from shared values
// => runs on UI thread: no JS involvement per frame
return 20 + progress.value * 60;
// => radius: 20 when progress=0, 80 when progress=1
}, [progress]);
// => radius: Skia-compatible derived value (not a plain number)
const opacity = useDerivedValue(() => {
return 0.3 + progress.value * 0.7;
// => opacity: 0.3 → 1.0 as progress goes 0 → 1
}, [progress]);
return (
<View style={styles.container}>
<Canvas style={{ width: 200, height: 200 }}>
<Circle
cx={100}
cy={100}
r={radius} // => r: derived value (not number) — animated by UI thread
opacity={opacity} // => opacity: derived value, also animated
color="#0173B2"
/>
{/* => Circle animates continuously without any React re-renders */}
{/* => Reanimated shared value → Skia derived value → GPU draw call */}
</Canvas>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f9f9f9' },
});Key Takeaway: Use Skia's useDerivedValue to create Skia-compatible values from Reanimated shared values. The entire animation pipeline runs on the UI thread: Reanimated computes the value → Skia reads it → GPU draws the frame. Zero JS involvement per frame.
Why It Matters: The Reanimated + Skia combination is the state of the art for React Native animations. The pipeline — Reanimated shared value on UI thread → Skia derived value → Metal/Vulkan GPU draw call — achieves 120fps ProMotion display performance on iPhone Pro models without any JavaScript execution per frame. This enables real-time data visualizations (live stock charts, audio waveforms, sensor graphs) that update at display frame rate. Production apps use this for loading animations, progress indicators, and interactive chart brushing.
Group 18: Advanced Patterns
Example 64: Shared Element Transitions — Hero Animations
Shared Element Transitions (Reanimated 4) animate a component from its source position in one screen to its destination in another screen.
import Animated, {
useSharedTransitionStyle,
SharedTransition,
} from 'react-native-reanimated';
// => useSharedTransitionStyle: marks view as shared element participant
// => SharedTransition: configures transition behavior
import { router } from 'expo-router';
import { Pressable, View, Text, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
const PRODUCTS = [
{ id: '1', name: 'Laptop Pro', price: 1299, image: 'https://picsum.photos/200/200?random=1' },
{ id: '2', name: 'Wireless Earbuds', price: 199, image: 'https://picsum.photos/200/200?random=2' },
];
// => Product list screen
export function ProductListScreen() {
return (
<View style={styles.list}>
{PRODUCTS.map(product => (
<Pressable
key={product.id}
onPress={() => router.push({ pathname: '/product/[id]', params: { id: product.id } })}
>
<Animated.View sharedTransitionTag={`product-card-${product.id}`}>
{/* => sharedTransitionTag: unique string linking source + destination */}
{/* => must match the tag in the detail screen */}
<Image source={product.image} style={styles.thumbnail} contentFit="cover" />
</Animated.View>
<Text style={styles.productName}>{product.name}</Text>
</Pressable>
))}
</View>
);
}
// => Product detail screen (app/product/[id].tsx)
export function ProductDetailScreen() {
const { id } = { id: '1' }; // => from useLocalSearchParams in real code
const product = PRODUCTS.find(p => p.id === id)!;
return (
<View style={styles.detail}>
<Animated.View
sharedTransitionTag={`product-card-${product.id}`}
// => SAME tag as list screen: Reanimated interpolates between the two positions
sharedTransitionStyle={SharedTransition.custom((values) => {
'worklet';
return {
width: withTiming(values.targetWidth, { duration: 400 }),
height: withTiming(values.targetHeight, { duration: 400 }),
// => custom: control individual properties of the transition
};
})}
>
<Image source={product.image} style={styles.heroImage} contentFit="cover" />
</Animated.View>
<Text style={styles.detailName}>{product.name}</Text>
<Text style={styles.detailPrice}>${product.price}</Text>
</View>
);
}
const styles = StyleSheet.create({
list: { flex: 1, padding: 16, gap: 12 },
thumbnail: { width: '100%', height: 120, borderRadius: 8 },
productName: { fontSize: 14, fontWeight: '600', marginTop: 6 },
detail: { flex: 1, gap: 16 },
heroImage: { width: '100%', height: 300 },
detailName: { fontSize: 22, fontWeight: '700', paddingHorizontal: 16 },
detailPrice: { fontSize: 18, color: '#029E73', paddingHorizontal: 16 },
});
function withTiming(v: number, options: any) { return v; } // => placeholder for demoKey Takeaway: Add matching sharedTransitionTag string props to source and destination components. Reanimated interpolates position, size, and opacity between the two views during navigation. Use SharedTransition.custom() for precise control over the transition curve.
Why It Matters: Shared element transitions are the signature animation of modern mobile UIs — Google Photos' expanding image, iOS App Store's card-to-fullscreen transition, Airbnb's listing expansion. They provide visual continuity that helps users understand where they navigated. Reanimated 4's implementation runs entirely on the UI thread via Fabric's synchronous layout queries, enabling accurate source position detection even for views inside scrolled lists. This was the most-requested animation feature in React Native history, finally productionized in Reanimated 4.
Example 65: Layout Animations — Entering, Exiting, Layout
Reanimated 4 layout animations automate the animation of component mount, unmount, and reflow — no manual animation code.
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutLeft,
Layout,
ZoomIn,
BounceIn,
LinearTransition,
} from 'react-native-reanimated';
import { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
export default function LayoutAnimationDemo() {
const [items, setItems] = useState(['Apple', 'Banana', 'Cherry']);
const [showBanner, setShowBanner] = useState(false);
const addItem = () => {
const fruits = ['Date', 'Elderberry', 'Fig', 'Grape'];
const newFruit = fruits[Math.floor(Math.random() * fruits.length)];
setItems(prev => [...prev, newFruit]);
// => Adding item triggers 'entering' animation on new item
// => Other items trigger 'layout' animation to shift positions
};
const removeItem = (index: number) => {
setItems(prev => prev.filter((_, i) => i !== index));
// => Removing item triggers 'exiting' animation on removed item
// => Remaining items trigger 'layout' animation to fill the gap
};
return (
<View style={styles.container}>
{showBanner && (
<Animated.View
entering={SlideInRight.duration(300)} // => slides in from right when mounted
exiting={SlideOutLeft.duration(300)} // => slides out to left when unmounted
style={styles.banner}
>
<Text style={styles.bannerText}>New Feature Available!</Text>
</Animated.View>
)}
<View style={styles.list}>
{items.map((item, index) => (
<Animated.View
key={item}
entering={FadeIn.duration(300).delay(index * 50)}
// => entering: FadeIn when component mounts to React tree
// => delay(index * 50): stagger each item by 50ms
exiting={FadeOut.duration(200)}
// => exiting: FadeOut when component unmounts from React tree
layout={LinearTransition.duration(250)}
// => layout: LinearTransition when sibling items shift position
// => fired when OTHER items are added/removed
style={styles.item}
>
<Text style={styles.itemText}>{item}</Text>
<Pressable onPress={() => removeItem(index)} style={styles.removeButton}>
<Text style={styles.removeText}>×</Text>
</Pressable>
</Animated.View>
))}
</View>
<View style={styles.controls}>
<Pressable style={styles.button} onPress={addItem}>
<Text style={styles.buttonText}>Add Item</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => setShowBanner(v => !v)}>
<Text style={styles.buttonText}>Toggle Banner</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 12 },
banner: { backgroundColor: '#DE8F05', padding: 12, borderRadius: 8 },
bannerText: { color: '#fff', fontWeight: '700' },
list: { gap: 6 },
item: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#fff', padding: 14, borderRadius: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
itemText: { fontSize: 16 },
removeButton: { paddingHorizontal: 8, paddingVertical: 2 },
removeText: { fontSize: 22, color: '#c0392b' },
controls: { flexDirection: 'row', gap: 12 },
button: { flex: 1, backgroundColor: '#0173B2', padding: 14, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '600' },
});Key Takeaway: Add entering={FadeIn}, exiting={FadeOut}, and layout={LinearTransition} props to Animated.View. Reanimated automatically animates mount, unmount, and reflow. Chain .duration(), .delay(), .springify() for customization.
Why It Matters: Layout animations are the highest-impact per-effort animation feature in React Native. Adding three props to a list item container makes add/remove operations feel polished and professional rather than abrupt. The layout prop is particularly powerful — when you remove item 2 from a 5-item list, items 3-5 smoothly slide up to fill the gap rather than jumping. Reanimated 4 computes layout animations in C++ using Fabric's synchronous layout engine, ensuring animations respect the actual layout positions rather than approximating them.
Group 19: Data and ORM
Example 66: WatermelonDB — Reactive SQLite ORM
WatermelonDB provides a reactive, lazy-loading ORM on top of SQLite — perfect for apps with complex relational data and real-time UI updates.
import { Database, Model, Q, tableSchema, appSchema } from '@nozbe/watermelondb';
import { field, date, children, relation, readonly } from '@nozbe/watermelondb/decorators';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
// => @nozbe/watermelondb: reactive SQLite ORM for complex relational data
// => 1. Define schema
const schema = appSchema({
version: 1, // => increment to trigger migration
tables: [
tableSchema({
name: 'posts',
columns: [
{ name: 'title', type: 'string' },
{ name: 'body', type: 'string' },
{ name: 'author_id', type: 'string', isIndexed: true },
// => isIndexed: create SQLite index for faster queries
{ name: 'created_at', type: 'number' },
{ name: 'updated_at', type: 'number' },
],
}),
],
});
// => 2. Define Model class
class Post extends Model {
static table = 'posts'; // => must match tableSchema name
@field('title') title!: string; // => maps 'title' column to JS property
@field('body') body!: string;
@readonly @date('created_at') createdAt!: Date;
// => @readonly: no direct writes (set via database.write())
// => @date: auto-converts Unix timestamp ↔ Date object
@readonly @date('updated_at') updatedAt!: Date;
}
// => 3. Initialize database
const adapter = new SQLiteAdapter({
schema,
// => migrations: array of schema migrations for version upgrades
// => jsi: true — uses JSI for synchronous SQLite access (faster)
});
const database = new Database({
adapter,
modelClasses: [Post], // => register all model classes
});
// => 4. Usage with React hooks
import { withDatabase, withObservables, compose } from '@nozbe/watermelondb/react';
import { useCallback } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
function PostsList({ posts }: { posts: Post[] }) {
// => posts: Post[] — automatically re-renders when posts change in DB
const addPost = useCallback(async () => {
await database.write(async () => {
// => database.write(): wraps mutations in a transaction
await database.get<Post>('posts').create(post => {
post.title = 'New Post'; // => set fields in create callback
post.body = 'Post content here';
});
});
// => withObservables subscribers (this component) auto-update
}, []);
return (
<View style={styles.container}>
{posts.map(post => (
<View key={post.id} style={styles.post}>
<Text style={styles.title}>{post.title}</Text>
<Text style={styles.body} numberOfLines={2}>{post.body}</Text>
</View>
))}
<Pressable style={styles.button} onPress={addPost}>
<Text style={styles.buttonText}>Add Post</Text>
</Pressable>
</View>
);
}
// => enhance: connects component to WatermelonDB observable query
const enhance = withObservables([], ({ database }: any) =>
({
posts: database.get('posts').query().observe(),
// => observe(): returns RxJS Observable that emits on any DB change
// => withObservables auto-subscribes and triggers re-render
})
);
export default compose(withDatabase, enhance)(PostsList);
// => compose: applies HOCs in order: withDatabase provides db, enhance provides posts
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 8 },
post: { backgroundColor: '#fff', padding: 14, borderRadius: 8 },
title: { fontSize: 15, fontWeight: '700' },
body: { fontSize: 13, color: '#666', marginTop: 4 },
button: { backgroundColor: '#0173B2', padding: 14, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '600' },
});Key Takeaway: Define schema → extend Model with @field decorators → use database.write() for mutations. observe() makes queries reactive — components re-render automatically when database changes.
Why It Matters: WatermelonDB is optimized for mobile offline-first apps with complex data. Unlike plain SQLite (Example 53) where you manually re-query after mutations, WatermelonDB's observe() pattern automatically updates all subscribed components when data changes — like a reactive ORM. Its lazy-loading architecture (only loads data on-demand) enables 10,000+ record apps without memory issues. The synchronous SQLite adapter via JSI makes initial queries fast enough for list rendering. Shopify uses WatermelonDB in production across their merchant mobile apps.
Example 67: Offline-First Architecture
Optimistic updates, sync queues, and conflict resolution enable apps to work seamlessly without internet connectivity.
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { MMKV } from "react-native-mmkv";
import NetInfo from "@react-native-community/netinfo";
import { useEffect } from "react";
type SyncStatus = "synced" | "pending" | "conflict";
type QueuedAction = {
id: string; // => unique action ID
type: "CREATE" | "UPDATE" | "DELETE";
entity: string; // => 'post' | 'comment' | 'task'
payload: Record<string, unknown>;
timestamp: number; // => client timestamp for conflict detection
attempts: number; // => retry count
};
const mmkv = new MMKV({ id: "sync-queue" });
type OfflineStore = {
queue: QueuedAction[];
isOnline: boolean;
syncStatus: SyncStatus;
enqueue: (action: Omit<QueuedAction, "id" | "timestamp" | "attempts">) => void;
processQueue: () => Promise<void>;
setOnline: (online: boolean) => void;
};
const useOfflineStore = create<OfflineStore>()(
persist(
(set, get) => ({
queue: [], // => pending actions to sync
isOnline: true,
syncStatus: "synced",
enqueue: (action) => {
const queued: QueuedAction = {
...action,
id: `action-${Date.now()}-${Math.random()}`,
timestamp: Date.now(),
attempts: 0,
};
set((state) => ({ queue: [...state.queue, queued], syncStatus: "pending" }));
// => Optimistic update: apply locally immediately, sync later
},
processQueue: async () => {
const { queue, isOnline } = get();
if (!isOnline || queue.length === 0) return;
// => Only process when online and queue has items
const failed: QueuedAction[] = [];
for (const action of queue) {
try {
await fetch(`https://api.example.com/${action.entity}`, {
method: action.type === "CREATE" ? "POST" : action.type === "UPDATE" ? "PATCH" : "DELETE",
body: JSON.stringify(action.payload),
headers: {
"Content-Type": "application/json",
"X-Client-Timestamp": String(action.timestamp),
// => timestamp header: server uses for conflict detection
// => Last-write-wins: server applies action if client timestamp > server timestamp
},
});
// => success: action removed from queue (not added to failed[])
} catch {
if (action.attempts < 3) {
failed.push({ ...action, attempts: action.attempts + 1 });
// => retry up to 3 times before dropping
}
}
}
set({ queue: failed, syncStatus: failed.length > 0 ? "pending" : "synced" });
},
setOnline: (online) => set({ isOnline: online }),
}),
{
name: "offline-sync-queue",
storage: {
setItem: (k, v) => mmkv.set(k, v),
getItem: (k) => mmkv.getString(k) ?? null,
removeItem: (k) => mmkv.delete(k),
},
},
),
);
// => Network listener + auto-sync hook
function useNetworkSync() {
const { processQueue, setOnline } = useOfflineStore();
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
const online = Boolean(state.isConnected && state.isInternetReachable);
setOnline(online);
if (online) processQueue(); // => auto-process queue when network returns
});
return unsubscribe;
}, []);
}Key Takeaway: Queue mutations locally in MMKV-backed Zustand. Apply optimistic updates to UI immediately. Process the queue when network is available. Include timestamps for server-side conflict detection.
Why It Matters: Offline-first architecture is the gold standard for mobile apps serving users with unreliable connectivity (traveling, rural areas, developing markets). Optimistic updates eliminate the "spinner wait" UX — the user's action takes effect instantly and syncs silently in the background. The timestamp-based conflict resolution (last-write-wins) handles the case where the same item was edited on two devices while offline. Apps with offline support report 40-60% higher session lengths in markets with spotty connectivity because users never encounter "no internet" blocking screens.
Group 20: Performance
Example 68: Performance Profiling with Hermes and React DevTools
Identify and fix performance bottlenecks using the Hermes sampling profiler and React Native DevTools.
// Profiling setup — enable in development builds only
import { Performance } from 'react-native';
// => 1. User timing API: mark custom performance events
const markStart = (name: string) => Performance.mark(`${name}-start`);
const markEnd = (name: string) => {
Performance.mark(`${name}-end`);
Performance.measure(name, `${name}-start`, `${name}-end`);
// => visible in Hermes profiler timeline as a labeled span
};
// => 2. Component render profiling with React DevTools
// => Open React Native DevTools: npx react-native start → press 'j'
// => Or: adb reverse tcp:8097 tcp:8097 → open chrome://inspect → DevTools tab
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id, // => component tree identifier
phase, // => 'mount' | 'update' | 'nested-update'
actualDuration, // => time spent rendering (ms)
baseDuration, // => estimated time without memoization (ms)
) => {
if (actualDuration > 16) {
// => 16ms = 60fps threshold: longer = dropped frame
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(1)}ms (${phase})`);
}
};
// => Wrap slow components with Profiler
import { View, Text } from 'react-native';
function SlowComponent({ data }: { data: string[] }) {
markStart('SlowComponent-render');
// => Measure starts here (before render work)
const processed = data.map(item => item.toUpperCase());
// => Expensive: called on every render
markEnd('SlowComponent-render');
return (
<Profiler id="SlowComponent" onRender={onRender}>
<View>
{processed.map((item, i) => <Text key={i}>{item}</Text>)}
</View>
</Profiler>
);
}
// => 3. Hermes Sampling Profiler (CLI)
// => Enable in app: Settings → Debug → Enable Sampling Profiler
// => Or via CLI: adb shell am profile start <pid> /sdcard/profile.trace
// => Download: adb pull /sdcard/profile.trace ./profile.trace
// => Analyze in chrome://tracing or React Native DevTools Performance tab
// => Common findings from profiling:
// => - Re-renders from unstable object references (fix: useMemo/useCallback)
// => - Expensive list item renders (fix: React.memo + stable keyExtractor)
// => - Layout thrash from inline style objects (fix: StyleSheet.create)
// => - Bridge serialization (fix: move to worklets/native)Key Takeaway: Use Performance.mark() + Performance.measure() for custom timing. Wrap expensive components with <Profiler> and log renders exceeding 16ms. Use the Hermes sampling profiler for deep CPU analysis.
Why It Matters: You cannot optimize what you do not measure. The 16ms budget (60fps) is the fundamental constraint — any render taking longer drops a frame, causing visible jank. React's Profiler component identifies which components are slow and whether they are slow on mount vs update (different fixes: memo vs useMemo). The Hermes sampling profiler reveals exact JS function call stacks that consume CPU time — often revealing surprising hot paths like moment.js date formatting in list renders or excessive Zustand subscription callbacks.
Example 69: Metro Bundle Optimization
Reduce JavaScript bundle size using Metro's built-in tree shaking, import analysis, and source map visualization.
// metro.config.js — Metro 0.84.3
const { getDefaultConfig } = require("expo/metro-config");
const config = getDefaultConfig(__dirname);
// => 1. Enable tree shaking (removes unused exports)
config.transformer.minifierConfig = {
compress: {
dead_code: true, // => remove unreachable code
drop_console: true, // => remove console.log() in production
pure_funcs: ["console.info", "console.debug"],
// => pure_funcs: treat these as side-effect-free (safe to remove if unused)
},
};
// => 2. Bundle analysis: generate stats for visualization
// => npx expo export --platform ios -- --bundle-stats-bundle-size
// => Opens a bundle visualizer (like webpack-bundle-analyzer)
// => 3. Custom resolver for platform-specific optimizations
config.resolver.resolveRequest = (context, moduleName, platform) => {
// => Redirect heavy library to lighter alternative on mobile
if (moduleName === "lodash" && platform === "android") {
return { type: "sourceFile", filePath: require.resolve("lodash-es") };
// => lodash-es: tree-shakeable ES module version (smaller bundles)
}
return context.resolveRequest(context, moduleName, platform);
};
// => 4. Lazy import for heavy screens
// => Use dynamic import() for rarely-visited screens
// => Metro splits them into separate bundles loaded on demand
module.exports = config;
// => Common bundle size wins:
// => moment.js → date-fns (100KB → 2KB per used function)
// => lodash → lodash-es with tree shaking (70KB → 2KB per used function)
// => @expo/vector-icons: only import used icon sets
// => Use: import { Ionicons } from '@expo/vector-icons' (tree-shakeable)
// => Avoid: import * as Icons from '@expo/vector-icons' (imports everything)Key Takeaway: Enable dead code elimination in metro.config.js. Use tree-shakeable library alternatives (lodash-es, date-fns). Analyze bundle composition with Metro's built-in stats to identify large dependencies.
Why It Matters: Bundle size directly impacts app startup time — every 100KB of JS takes ~30ms to parse on mid-range Android devices (Hermes bytecode compilation helps but parsing still occurs on first launch before .hbc cache). A 3MB bundle instead of 1MB means 600ms extra cold start time. This is the difference between a 1s and 1.6s launch — a critical metric measured by Apple's Xcode Organizer and Google Play Console. Tree shaking unused library code is the highest-ROI optimization: replacing moment.js with date-fns saves 400KB with zero behavioral change.
Example 70: FlatList/FlashList Performance Tuning
Configure FlatList and FlashList for maximum scroll performance on large datasets.
import { FlatList, View, Text, StyleSheet } from 'react-native';
import { memo, useCallback } from 'react';
type Item = { id: string; height: number; title: string };
const ITEMS: Item[] = Array.from({ length: 1000 }, (_, i) => ({
id: `item-${i}`,
height: 60 + Math.random() * 40, // => variable height: 60-100pt
title: `Item ${i + 1}`,
}));
const ITEM_HEIGHT = 80; // => use fixed height for getItemLayout optimization
const ListItem = memo(({ item }: { item: Item }) => {
// => memo(): prevents re-render when parent re-renders with same props
// => CRITICAL: every FlatList renderItem should be memo'd
return (
<View style={[styles.item, { height: item.height }]}>
<Text style={styles.title}>{item.title}</Text>
</View>
);
});
export default function PerformanceTunedList() {
const keyExtractor = useCallback((item: Item) => item.id, []);
// => useCallback: stable keyExtractor reference prevents FlatList re-render
const renderItem = useCallback(({ item }: { item: Item }) => (
<ListItem item={item} />
), []);
// => useCallback: stable renderItem reference is critical for FlatList optimization
const getItemLayout = useCallback((_: any, index: number) => ({
length: ITEM_HEIGHT, // => item height in pts
offset: ITEM_HEIGHT * index, // => distance from list top to this item
index,
}), []);
// => getItemLayout: enables O(1) scroll position calculation (no measurement)
// => without: FlatList must layout all items to calculate scroll position
// => with: scrollToIndex works instantly, even for index 999 in a 1000-item list
// => only usable with FIXED height items
return (
<FlatList
data={ITEMS}
keyExtractor={keyExtractor}
renderItem={renderItem}
getItemLayout={getItemLayout} // => provide only for fixed-height items
removeClippedSubviews={true} // => unmounts off-screen views (saves memory)
// => NOTE: can cause blank areas during very fast scroll
initialNumToRender={10} // => render 10 items on initial mount (minimum visible)
maxToRenderPerBatch={10} // => render up to 10 items per JS batch
windowSize={10} // => render 10 screen-heights worth of items
// => windowSize: (default 21) reduce for memory savings, increase for scroll smoothness
updateCellsBatchingPeriod={50} // => batch cell renders in 50ms windows
// => updateCellsBatchingPeriod: tradeoff — lower = smoother, higher = fewer re-renders
contentContainerStyle={styles.content}
/>
);
}
const styles = StyleSheet.create({
content: { padding: 8 },
item: {
backgroundColor: '#fff',
marginBottom: 4,
padding: 16,
borderRadius: 6,
justifyContent: 'center',
},
title: { fontSize: 15, fontWeight: '500' },
});Key Takeaway: Wrap renderItem components with memo(). Stabilize keyExtractor and renderItem with useCallback. Provide getItemLayout for fixed-height items to enable O(1) scroll calculations. Tune windowSize and maxToRenderPerBatch for memory/smoothness tradeoff.
Why It Matters: Unoptimized FlatList is the most common performance issue in React Native apps. The memo() + useCallback combination prevents the most expensive operation: React re-rendering all visible list items when parent state changes (e.g., a header input field updates). getItemLayout unlocks instant scrollToIndex for fixed-height lists — critical for "scroll to today" in calendar apps or "scroll to unread" in messaging apps. windowSize controls the memory/smoothness tradeoff: smaller values free memory (important on 3GB RAM Android devices) at the cost of more blank frames during fast scroll.
Example 71: Memoization — When and When Not to Use
React.memo, useMemo, and useCallback prevent unnecessary re-renders — but misused memoization adds overhead without benefit.
import { memo, useMemo, useCallback, useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
// => CORRECT: memo on expensive component with stable props
const ProductCard = memo(({ name, price, onPress }: {
name: string;
price: number;
onPress: () => void;
}) => {
// => memo: skips re-render if name, price, and onPress are same reference
const formatted = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR' }).format(price);
// => Intl.NumberFormat: expensive — justified inside memo'd component
return (
<Pressable onPress={onPress} style={styles.card}>
<Text style={styles.cardName}>{name}</Text>
<Text style={styles.cardPrice}>{formatted}</Text>
</Pressable>
);
});
export default function MemoDemo() {
const [count, setCount] = useState(0);
const [filter, setFilter] = useState('');
const products = [
{ id: '1', name: 'Laptop', price: 15000000 },
{ id: '2', name: 'Phone', price: 8000000 },
];
// => CORRECT: useMemo for expensive computation with changing inputs
const filteredProducts = useMemo(() => {
return products.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()));
// => filter runs only when products or filter changes — not on count change
}, [filter]);
// => WITHOUT useMemo: re-filters on every count increment (wasteful)
// => WITH useMemo: filters only when filter input changes (correct)
// => CORRECT: useCallback for stable function reference
const handlePress = useCallback((id: string) => {
console.log('Pressed:', id);
}, []);
// => WITHOUT useCallback: new function reference on every render → memo() useless
// => WITH useCallback: stable reference → memo() works as expected
// => WRONG: useMemo for trivial computation (overhead > benefit)
const doubleCount = count * 2; // => this is fine: no useMemo needed for simple math
// => const doubleCount = useMemo(() => count * 2, [count]); // => OVERKILL
// => WRONG: useCallback for inline handler used once (no memo benefit)
const handleIncrement = () => setCount(c => c + 1);
// => No child component receives this as prop, so useCallback adds no value
return (
<View style={styles.container}>
<Text style={styles.count}>Count: {count} (double: {doubleCount})</Text>
<Pressable onPress={handleIncrement} style={styles.button}>
<Text style={styles.buttonText}>Increment (triggers re-render)</Text>
</Pressable>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
name={product.name}
price={product.price}
onPress={() => handlePress(product.id)}
// => handlePress is stable (useCallback) → memo() works
// => () => handlePress(product.id) is NEW on every render
// => Pass handlePress directly (bind id via data attribute pattern)
/>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, gap: 12 },
count: { fontSize: 16 },
button: { backgroundColor: '#0173B2', padding: 12, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '600' },
card: { backgroundColor: '#fff', padding: 14, borderRadius: 8 },
cardName: { fontSize: 15, fontWeight: '600' },
cardPrice: { color: '#029E73' },
});Key Takeaway: Use memo() only for components that re-render frequently with same props. Pair with useCallback on handler props for memo to be effective. Use useMemo only for computationally expensive derivations. Avoid over-memoizing trivial values.
Why It Matters: Indiscriminate memoization is a common senior React Native developer mistake — adding memo() everywhere and then wondering why performance is worse. Every useMemo/useCallback call allocates memory and runs a dependency comparison. For a trivial computation (count * 2), the memo overhead exceeds the render cost. Effective memoization requires understanding the re-render tree: profiling with React DevTools Profiler to find components that render >16ms with unchanged props, then adding memo() + useCallback only where the benchmark shows improvement.
Example 72: Hermes Bytecode and Startup Optimization
Hermes compiles JavaScript to bytecode (.hbc) at build time, eliminating JS parse time on app startup.
// app.json — enable Hermes (default in Expo SDK 55, opt-in for v1)
{
"expo": {
"jsEngine": "hermes",
// => "hermes": enables Hermes v1 (opt-in in SDK 55 for significant perf improvement)
// => "jsc": JavaScriptCore (legacy, no bytecode compilation)
"android": { "jsEngine": "hermes" },
"ios": { "jsEngine": "hermes" }
}
}
// eas.json — Hermes bytecode compilation happens during EAS Build
{
"build": {
"production": {
"android": { "buildType": "apk" },
"env": { "NODE_ENV": "production" }
// => EAS Build runs react-native bundle --hermes-flags during production builds
// => Output: index.android.bundle.hbc (precompiled bytecode)
// => App loads .hbc directly: NO JS parse time on device
}
}
}// Startup time profiling — measure JS engine initialization
import { PerformanceObserver } from "react-native";
// => Hermes startup breakdown:
// => Phase 1: Load bytecode (mmap .hbc file — near-instant)
// => Phase 2: Execute module initialization (top-level require() calls)
// => Phase 3: React rendering (first render to screen)
// => Common startup blockers (fix these first):
// => 1. Heavy top-level imports: import at module top level blocks parse
// BAD: import * as _ from 'lodash'; // => loads entire lodash at startup
// GOOD: const { debounce } = await import('lodash/debounce'); // => lazy
// => 2. Synchronous storage reads blocking first render:
// BAD: const token = AsyncStorage.getItem('token'); // => await blocks startup
// GOOD: const token = mmkv.getString('session.token'); // => synchronous, fast
// => 3. Large initial state computation:
// BAD: const [state] = useState(() => processLargeDataset()); // => blocks first render
// GOOD: defer to useEffect: show skeleton → process → update state
// => Hermes v1 improvements (SDK 55 opt-in):
// => - Lazy function compilation: functions compiled only when first called
// => - Improved GC: concurrent mark-and-sweep reduces pause times
// => - Smaller .hbc bytecode: better compression → faster mmap
// => Benchmark: Hermes v1 vs v0: ~15-30% faster app startup
// => EAS Update with Hermes v1: ~75% reduction in OTA update download size
// => (Hermes bytecode diffing: only changed functions recompiled)Key Takeaway: Enable Hermes v1 via "jsEngine": "hermes" in app.json. Hermes eliminates JS parse time by shipping precompiled bytecode. Lazy imports, MMKV over AsyncStorage, and deferred computations reduce startup time further.
Why It Matters: App startup time is a primary retention signal — Google Play Console reports abandonment rates spike significantly for cold starts over 2 seconds. Hermes bytecode elimination of the parse phase reduces startup by 30-40% compared to JavaScriptCore. Hermes v1 (opt-in in SDK 55) adds lazy function compilation — functions not called at startup are not compiled until needed, making the critical path from app launch to first interactive frame as short as possible. EAS Update's Hermes bytecode diffing reduces OTA update sizes by 75% — critical for user adoption of updates.
Group 21: Testing
Example 73: Unit Testing — @react-native/jest-preset
React Native 0.85 extracts the Jest preset to @react-native/jest-preset as a separate package. Update jest.config.js to use the new package.
// jest.config.js — React Native 0.85+ (NEW separate package)
module.exports = {
preset: "@react-native/jest-preset",
// => CRITICAL: In RN 0.85, jest preset moved from 'react-native' to '@react-native/jest-preset'
// => Old (pre-0.85): preset: 'react-native' (still works but deprecated)
// => New (0.85+): preset: '@react-native/jest-preset' (install separately)
setupFilesAfterFramework: [
"@testing-library/react-native/extend-expect",
// => adds custom matchers: toBeVisible(), toHaveTextContent(), etc.
],
transformIgnorePatterns: [
"node_modules/(?!(react-native|@react-native|expo|@expo|@shopify/flash-list)/)",
// => transformIgnorePatterns: Jest ignores node_modules by default
// => Exception: RN libraries need Babel transform (contain ES module syntax)
// => Add all @expo/* and @react-native/* packages to the exception list
],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1", // => path alias for @/
"^@components/(.*)$": "<rootDir>/src/components/$1",
// => must mirror babel.config.js aliases for Jest module resolution
},
};// src/components/__tests__/ProductCard.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
// => @testing-library/react-native: query by accessible text, role, testID
import { ProductCard } from '../ProductCard';
describe('ProductCard', () => {
const mockProduct = {
id: '1',
name: 'Laptop Pro',
price: 15000000,
};
it('renders product name and formatted price', () => {
render(<ProductCard product={mockProduct} onPress={jest.fn()} />);
expect(screen.getByText('Laptop Pro')).toBeTruthy();
// => getByText: finds element with exact text
expect(screen.getByText(/15\.000\.000/)).toBeTruthy();
// => regex match: currency formatting varies by locale
});
it('calls onPress with product id when pressed', () => {
const onPress = jest.fn();
render(<ProductCard product={mockProduct} onPress={onPress} />);
fireEvent.press(screen.getByText('Laptop Pro'));
// => fireEvent.press: simulates a press event
expect(onPress).toHaveBeenCalledTimes(1);
expect(onPress).toHaveBeenCalledWith('1');
// => verify onPress called with correct product id
});
it('shows sale badge when product is on sale', () => {
render(<ProductCard product={{ ...mockProduct, onSale: true }} onPress={jest.fn()} />);
expect(screen.getByTestId('sale-badge')).toBeTruthy();
// => getByTestId: finds by testID prop (use for non-visible-text elements)
});
it('is accessible with correct role', () => {
render(<ProductCard product={mockProduct} onPress={jest.fn()} />);
const card = screen.getByRole('button');
// => getByRole: finds by ARIA role (button, link, heading, list, listitem, etc.)
expect(card).toBeTruthy();
});
});Key Takeaway: Install @react-native/jest-preset as a separate package in RN 0.85+. Use @testing-library/react-native for component tests — query by text, role, and testID. Prefer getByRole and getByText over getByTestId for accessible query patterns.
Why It Matters: The extraction of @react-native/jest-preset in RN 0.85 is a breaking change for existing Jest configurations — preset: 'react-native' still works with a deprecation warning but will break in future versions. Updating now prevents CI failures after upgrading. @testing-library/react-native's philosophy of querying by what users see (text, roles) rather than implementation details (component names, state) means tests remain valid through refactors. Tests that query by role also validate accessibility — a screen reader user sees the same elements your getByRole tests query.
Example 74: Mocking Native Modules in Jest
Native modules (camera, location, storage) need mocks in Jest because native code doesn't run in Node.js.
// __mocks__/react-native-mmkv.ts — manual mock for MMKV
const mmkvStore = new Map<string, string | boolean | number>();
const MMKV = jest.fn().mockImplementation(() => ({
// => jest.fn().mockImplementation: creates mock class instance
set: jest.fn((key: string, value: string | boolean | number) => {
mmkvStore.set(key, value);
// => mock implementation: store in in-memory Map
}),
getString: jest.fn((key: string) => mmkvStore.get(key) as string | undefined),
getBoolean: jest.fn((key: string) => mmkvStore.get(key) as boolean | undefined),
getNumber: jest.fn((key: string) => mmkvStore.get(key) as number | undefined),
delete: jest.fn((key: string) => mmkvStore.delete(key)),
clearAll: jest.fn(() => mmkvStore.clear()),
}));
export { MMKV };// jest.setup.ts — global mock setup
import { jest } from "@jest/globals";
// => Mock expo modules
jest.mock("expo-notifications", () => ({
getPermissionsAsync: jest.fn(() => Promise.resolve({ status: "granted" })),
requestPermissionsAsync: jest.fn(() => Promise.resolve({ status: "granted" })),
getExpoPushTokenAsync: jest.fn(() => Promise.resolve({ data: "ExponentPushToken[mock]" })),
setNotificationHandler: jest.fn(),
addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })),
// => returns object with remove() to simulate subscription cleanup
addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })),
}));
jest.mock("expo-location", () => ({
requestForegroundPermissionsAsync: jest.fn(() => Promise.resolve({ status: "granted" })),
getCurrentPositionAsync: jest.fn(() =>
Promise.resolve({
coords: { latitude: -6.2146, longitude: 106.8451, accuracy: 10, altitude: null, speed: null },
}),
),
Accuracy: { High: 4, Balanced: 3, Low: 2 },
}));
// => Platform-specific mocks
jest.mock("react-native/Libraries/Utilities/Platform", () => ({
OS: "ios", // => test as iOS by default
select: jest.fn((obj) => obj.ios ?? obj.default),
Version: "17.5",
isPad: false,
}));// Testing a component that uses mocked native modules
import { render, screen, waitFor } from '@testing-library/react-native';
import { LocationButton } from '../LocationButton';
test('shows location after permission granted', async () => {
render(<LocationButton />);
await waitFor(() => {
expect(screen.getByText('-6.2146, 106.8451')).toBeTruthy();
// => waitFor: waits for async state update from mocked location
});
// => Verify the mock was called correctly
const Location = require('expo-location');
expect(Location.requestForegroundPermissionsAsync).toHaveBeenCalledTimes(1);
expect(Location.getCurrentPositionAsync).toHaveBeenCalledWith(
expect.objectContaining({ accuracy: 4 }) // => Accuracy.High = 4
);
});Key Takeaway: Create __mocks__/module-name.ts files alongside the mocked modules. Use jest.fn().mockImplementation() to create stateful mocks. Use jest.mock('module-path', ...) for inline mocks in jest.setup.ts.
Why It Matters: Native module mocking is mandatory for React Native testing — Hermes runs in Node.js during Jest and has no access to device APIs (camera, location, Bluetooth). Without mocks, tests that use native modules throw TypeError: Module is not found. Automatic mocks (jest.genMockFromModule) work for simple modules but fail for modules with complex class instantiation (MMKV), subscription patterns (notifications), or event emitters. Manual mocks give precise control over returned values and allow testing error states (permission denied, network failure) that are hard to trigger in real device tests.
Example 75: E2E Testing with Detox
Detox provides gray-box E2E testing for React Native — tests run on real iOS Simulators/Android Emulators using a built app binary.
// e2e/login.test.ts — Detox test
import { device, element, by, expect as detoxExpect, waitFor } from "detox";
describe("Login Flow", () => {
beforeAll(async () => {
await device.launchApp({
// => device.launchApp: launch the built app binary
newInstance: true, // => fresh launch (clear state)
permissions: { notifications: "YES" },
// => grant permissions before launch (avoids permission dialog during test)
});
});
beforeEach(async () => {
await device.reloadReactNative();
// => reset React Native state between tests without full restart
});
it("should show login screen on cold launch", async () => {
await detoxExpect(element(by.id("login-screen"))).toBeVisible();
// => by.id: finds element by testID prop
// => toBeVisible: element is on screen and not hidden
});
it("should show error for invalid credentials", async () => {
await element(by.id("email-input")).typeText("invalid@test.com");
// => typeText: simulates keyboard input
await element(by.id("password-input")).typeText("wrongpassword");
await element(by.id("login-button")).tap();
// => tap: simulates a finger press
await waitFor(element(by.text("Invalid email or password")))
.toBeVisible()
.withTimeout(5000);
// => waitFor: polls until condition met (or timeout)
// => async: test waits for server response and UI update
});
it("should navigate to home after successful login", async () => {
await element(by.id("email-input")).typeText("valid@test.com");
await element(by.id("password-input")).typeText("correctpassword");
await element(by.id("login-button")).tap();
await waitFor(element(by.id("home-screen")))
.toBeVisible()
.withTimeout(10000);
// => 10s timeout: allows for auth API response + navigation animation
await detoxExpect(element(by.text("Welcome, valid@test.com"))).toBeVisible();
// => by.text: finds element by visible text content
});
it("should remember login after app background/foreground", async () => {
// Login first
await element(by.id("email-input")).typeText("valid@test.com");
await element(by.id("password-input")).typeText("correctpassword");
await element(by.id("login-button")).tap();
await waitFor(element(by.id("home-screen")))
.toBeVisible()
.withTimeout(10000);
await device.sendToHome(); // => background app
await device.launchApp({ newInstance: false }); // => foreground app
// => newInstance: false = resume same instance (not fresh launch)
await detoxExpect(element(by.id("home-screen"))).toBeVisible();
// => verify: still logged in after background/foreground cycle
});
});Key Takeaway: Detox tests run on the built app binary against real iOS Simulator or Android Emulator. Use by.id (testID), by.text, and by.type selectors. Use waitFor() for async operations. device.sendToHome() and device.launchApp() simulate app lifecycle.
Why It Matters: Detox's gray-box approach synchronizes with React Native's JS event loop, eliminating the flakiness of black-box E2E frameworks like Appium. When Detox taps a button, it waits for all pending network requests and animations to complete before proceeding — automatically. This makes Detox tests significantly more reliable than Appium-based tests. E2E tests catch integration bugs that unit tests miss: navigation flow breaks, state not persisting across reloads, deep link handling, push notification navigation. Running Detox in EAS Build CI on every PR prevents regressions from reaching production.
Group 22: Deployment
Example 76: EAS Build — Build Configuration
EAS Build compiles native iOS and Android binaries in the cloud from your Expo project — no local Xcode or Android Studio required for most workflows.
{
"cli": {
"version": ">= 14.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": false,
"buildConfiguration": "Debug"
},
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleDebug"
},
"env": {
"NODE_ENV": "development",
"API_URL": "https://dev.api.example.com"
}
},
"preview": {
"distribution": "internal",
"channel": "preview",
"ios": {
"buildConfiguration": "Release",
"simulator": false
},
"android": {
"buildType": "apk"
},
"env": {
"NODE_ENV": "production",
"API_URL": "https://staging.api.example.com"
}
},
"production": {
"channel": "production",
"ios": {
"buildConfiguration": "Release"
},
"android": {
"buildType": "app-bundle"
},
"env": {
"NODE_ENV": "production",
"API_URL": "https://api.example.com"
},
"cache": {
"key": "production-v1"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "developer@example.com",
"ascAppId": "1234567890"
},
"android": {
"serviceAccountKeyPath": "./google-services.json",
"track": "internal"
}
}
}
}# EAS Build commands
eas build --profile development --platform ios # => Build development IPA
# => development: includes Expo Dev Client for in-app debugging
eas build --profile preview --platform all # => Build both platforms as preview
# => preview: production-signed build for internal testing (TestFlight/Google Play Internal)
eas build --profile production --platform all # => Production build
# => production: App Store / Play Store ready binary
# => cache.key: invalidate build cache when dependencies change significantly
eas build:list # => List recent builds with status + download linksKey Takeaway: Configure three build profiles in eas.json: development (dev client), preview (internal testing), production (store submission). Use channel to link builds to EAS Update channels for OTA updates.
Why It Matters: EAS Build eliminates the local native build environment requirement — no Xcode licence, no Android NDK configuration, no CocoaPods install — the most common source of "works on my machine" issues in React Native teams. The cloud build cache (keyed by dependencies and native code) makes subsequent builds fast: only changed native modules are recompiled. The three-profile strategy matches the standard mobile release train (dev → internal testing → production), enabling the same codebase to build different configs (API endpoints, debug tools, signing certificates) from a single source of truth.
Example 77: EAS Update — Over-the-Air Updates
EAS Update pushes JavaScript bundle changes to deployed apps without requiring a new App Store submission.
# EAS Update commands
eas update --channel production --message "Fix login crash"
# => Bundles JS, uploads to EAS CDN, notifies devices on 'production' channel
# => Devices download update on next app foreground (or background check)
eas update --channel preview --platform ios --message "New feature: dark mode"
# => Platform-specific update: iOS gets dark mode, Android builds later
eas update:list --channel production # => List recent updates with deployment stats
# => Shows: download count, active installs, rollback status
eas update:rollback --channel production --group <update-group-id>
# => Rollback: revert channel to previous update immediately
# => All devices on channel download the reverted bundle on next check// In-app update check (expo-updates)
import * as Updates from "expo-updates";
import { useEffect } from "react";
import { Alert } from "react-native";
export function useOTAUpdates() {
useEffect(() => {
async function checkForUpdate() {
if (__DEV__) return; // => skip in development (no updates in dev)
try {
const update = await Updates.checkForUpdateAsync();
// => checkForUpdateAsync: queries EAS CDN for new bundle on current channel
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
// => fetchUpdateAsync: downloads new bundle (background download)
Alert.alert("Update Available", "A new version is ready. Restart to apply the update.", [
{ text: "Later", style: "cancel" },
{
text: "Restart",
onPress: async () => {
await Updates.reloadAsync();
// => reloadAsync: restarts the JS runtime with the new bundle
// => Native code unchanged — only JS/assets update
},
},
]);
}
} catch (error) {
console.error("OTA update check failed:", error);
// => Failure is non-fatal: users continue with current bundle
}
}
checkForUpdate();
}, []);
}Key Takeaway: eas update --channel <channel> pushes JS bundle changes without App Store review. Use checkForUpdateAsync + fetchUpdateAsync + reloadAsync for in-app update prompts. Roll back instantly with eas update:rollback.
Why It Matters: App Store review takes 24-48 hours for iOS. A production crash fix deployed via eas update reaches 100% of users within minutes of running the command — compared to days via App Store. EAS Update respects the 70/30 rule: only JS and assets can be updated OTA (native code changes require a new binary). The rollback capability is the safety net — if an update causes regressions, reverting to the previous bundle takes 30 seconds. EAS Update with Hermes v1 bytecode diffing reduces update download sizes by ~75%, making updates fast even on slow mobile connections.
Example 78: EAS Submit — Automated App Store Submission
EAS Submit automates App Store Connect and Google Play submission from the CLI.
# iOS submission
eas submit --platform ios --latest
# => --latest: submits the most recent production EAS Build
# => Requires: ASC API key (in eas.json submit.production.ios)
# => Uploads IPA to App Store Connect TestFlight automatically
eas submit --platform ios --path ./MyApp.ipa
# => Submit a specific IPA file (manual builds)
# Android submission
eas submit --platform android --latest
# => Submits .aab to Google Play Console
# => Requires: Google Play API service account JSON (serviceAccountKeyPath)
# => track: 'internal' | 'alpha' | 'beta' | 'production'
# Both platforms in parallel
eas submit --platform all --latest// app.json — versioning configuration
{
"expo": {
"version": "2.5.0",
// => version: user-visible version string (CFBundleShortVersionString on iOS)
"ios": {
"buildNumber": "42",
// => buildNumber: iTunes Connect build number (must increment with each submission)
// => Managed by EAS Build when "cli.appVersionSource": "remote"
},
"android": {
"versionCode": 42,
// => versionCode: Play Store version code (integer, must increment)
// => EAS Build auto-increments when "appVersionSource": "remote"
}
}
}Key Takeaway: eas submit --platform all --latest submits to both stores automatically. appVersionSource: "remote" in eas.json lets EAS manage version code/build number incrementing without manual file edits.
Why It Matters: Manual App Store submission involves Xcode Organizer, App Store Connect web UI, build validation, metadata upload, and TestFlight distribution — a multi-step process taking 20-30 minutes per platform. EAS Submit reduces this to one CLI command. appVersionSource: "remote" prevents the common version code conflict error (submitting the same build number twice) by tracking the current version server-side. Integrating eas submit into GitHub Actions CI (Example 80) enables fully automated release trains.
Example 79: Fastlane for Bare React Native
For bare (non-Expo) React Native projects, Fastlane automates code signing, building, and distribution.
# ios/fastlane/Fastfile
lane :setup_signing do
match(
type: 'appstore',
# => match: downloads certificates + provisioning profiles from private git repo
# => Encrypted in git: team members clone repo to set up signing
app_identifier: 'com.example.myapp',
readonly: true, # => don't regenerate certs (true for CI)
)
end
lane :beta do
setup_signing # => fetch signing certificates
increment_build_number(
xcodeproj: 'MyApp.xcodeproj',
build_number: latest_testflight_build_number + 1,
# => auto-increment: always one above current TestFlight build
)
gym(
# => gym: Xcode build and archive
scheme: 'MyApp',
configuration: 'Release',
output_directory: './build',
output_name: 'MyApp.ipa',
export_method: 'app-store', # => 'app-store' | 'ad-hoc' | 'enterprise'
)
pilot(
# => pilot: upload to TestFlight
ipa: './build/MyApp.ipa',
skip_waiting_for_build_processing: true,
# => skip_waiting: don't block CI on Apple's processing (takes 10-30 min)
distribute_external: false, # => internal testers only
)
end
lane :release do
setup_signing
gym(scheme: 'MyApp', configuration: 'Release', export_method: 'app-store')
deliver(
# => deliver: submit to App Store for review
force: true, # => don't prompt for confirmation
skip_screenshots: true, # => use existing screenshots
skip_metadata: false, # => update app metadata from metadata/ folder
submit_for_review: true,
automatic_release: false, # => manual release after approval
)
endKey Takeaway: Use Fastlane match for team-shared code signing, gym for building, pilot for TestFlight, and deliver for App Store. increment_build_number with latest_testflight_build_number + 1 prevents duplicate build number submission errors.
Why It Matters: Fastlane is the industry standard for bare React Native CI/CD. Expo projects should prefer EAS Build/Submit, but teams with custom native code, C++ extensions, or SDK requirements incompatible with EAS need bare workflow and Fastlane. match solves the biggest iOS team pain point: each developer previously needed access to Keychain sharing or their own certificates, causing "code signing failed" errors. match stores encrypted certificates in a git repo — developers run fastlane match appstore once to get signing set up.
Example 80: GitHub Actions CI Pipeline
Automate lint, test, and EAS builds on every push with GitHub Actions.
# .github/workflows/ci.yml
name: CI / EAS Build
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# => EXPO_TOKEN: EAS authentication token (set in GitHub repo secrets)
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm" # => cache node_modules
- name: Install dependencies
run: npm ci
# => npm ci: clean install (faster than npm install, uses package-lock.json)
- name: TypeScript check
run: npx tsc --noEmit
# => noEmit: type check without generating JS files
- name: Lint
run: npm run lint
- name: Unit Tests
run: npm test -- --coverage --forceExit
# => --coverage: generate coverage report
# => --forceExit: kill Jest after tests complete (prevents hanging)
eas-build:
runs-on: ubuntu-latest
needs: lint-and-test # => only build if lint + tests pass
if: github.ref == 'refs/heads/main' # => only on main branch pushes
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install EAS CLI
run: npm install -g eas-cli@latest
- name: Install dependencies
run: npm ci
- name: EAS Build (both platforms, production profile)
run: eas build --platform all --profile production --non-interactive
# => --non-interactive: no prompts (required for CI)
# => Builds queue on EAS workers, run asynchronously
# => CI job completes after build is queued (not after it finishes)
# => Optional: EAS Update on main push (OTA JS update only)
- name: EAS Update
if: github.event_name == 'push'
run: eas update --channel production --message "${{ github.event.head_commit.message }}" --non-interactiveKey Takeaway: Run TypeScript check, lint, and tests in a lint-and-test job. Gate EAS Build with needs: lint-and-test. Use --non-interactive flag for all EAS CLI commands in CI. Store EXPO_TOKEN in GitHub Secrets.
Why It Matters: CI prevents broken code from reaching production. The needs: dependency chain ensures EAS Build only runs after quality gates pass — preventing wasted cloud build minutes on code that fails type checking. eas update on every main push provides continuous OTA deployment of JS changes, while eas build on main creates new binaries when native dependencies change. The GitHub Actions ubuntu-latest runner handles the Node.js/npm work; the actual iOS/Android compilation happens on EAS's cloud workers (which have macOS for iOS builds). This architecture means CI passes quickly (3-5 minutes) while EAS builds run asynchronously.
Group 23: Production Readiness
Example 81: App Store and Play Store Release
Prepare for App Store and Google Play submission with iOS 17+ privacy manifests, semantic versioning, and screenshot requirements.
// app.json — production configuration checklist
{
"expo": {
"name": "MyApp",
"slug": "myapp",
"version": "2.5.0", // => semantic version: MAJOR.MINOR.PATCH
"orientation": "portrait", // => lock orientation (unless app supports landscape)
"userInterfaceStyle": "automatic", // => 'light' | 'dark' | 'automatic' (respects system)
"icon": "./assets/icon.png", // => 1024x1024 PNG, no alpha (iOS requirement)
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"bundleIdentifier": "com.example.myapp", // => unique reverse-domain ID
"buildNumber": "42", // => auto-managed by EAS
"supportsTablet": true,
"config": {
"usesNonExemptEncryption": false // => false: no custom encryption (avoids export compliance form)
},
"privacyManifests": {
// => iOS 17+ REQUIRED: declare API usage reasons
"NSPrivacyAccessedAPITypes": [
{
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
// => CA92.1: UserDefaults access for app functionality
// => Without this manifest: App Store rejection since May 2024
}
]
}
},
"android": {
"package": "com.example.myapp",
"versionCode": 42,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", // => 1024x1024 adaptive icon
"backgroundColor": "#0173B2"
},
"permissions": [
"CAMERA",
"ACCESS_FINE_LOCATION",
"POST_NOTIFICATIONS" // => Android 13+ requires explicit permission
]
}
}
}
// Screenshot requirements:
// iOS: 6.9" (iPhone 16 Pro Max), 12.9" (iPad Pro) — mandatory sizes
// Android: Phone (1080x1920+), 7" tablet, 10" tablet
// Generate with: npx expo export --platform web → screenshot in browser at correct size
// Or use ScreenshotBot / Fastlane snapshot for automated screenshotsKey Takeaway: iOS 17+ requires privacy manifests declaring API access reasons — missing these causes App Store rejection. Lock orientation, set correct bundleIdentifier/package, and provide all required screenshot sizes before submission.
Why It Matters: Apple began enforcing privacy manifests for the top 200 SDKs in May 2024 — apps accessing UserDefaults, file timestamps, or disk space APIs without manifest declarations are rejected. This impacts nearly every React Native app. Android 13 added POST_NOTIFICATIONS as a runtime permission — existing apps targeting Android 13+ that don't declare and request this permission silently fail to show push notifications. Understanding these requirements before the first submission prevents the frustrating cycle of rejection → fix → resubmit (48h delay each cycle).
Example 82: Sentry — Crash Reporting and Performance Monitoring
Sentry captures crashes, JavaScript errors, and performance issues in production with React Native source maps.
import * as Sentry from "@sentry/react-native";
// => @sentry/react-native: crash reporting + APM for React Native
Sentry.init({
dsn: "https://your-dsn@sentry.io/project-id",
// => dsn: Data Source Name — endpoint for your Sentry project
environment: __DEV__ ? "development" : process.env.NODE_ENV,
// => environment: 'development' | 'staging' | 'production' (filter in Sentry dashboard)
tracesSampleRate: 0.2,
// => tracesSampleRate: 20% of sessions tracked for performance monitoring
// => 1.0 = 100% (high cost), 0.1 = 10% (low cost, less data)
profilesSampleRate: 0.1,
// => profilesSampleRate: 10% of traces include CPU profiling data
integrations: [
Sentry.reactNativeTracingIntegration({
routingInstrumentation: Sentry.reactNavigationInstrumentation(),
// => Tracks navigation transitions as Sentry traces
}),
],
beforeSend(event) {
// => beforeSend: modify or filter events before sending
if (__DEV__) {
return null; // => don't send errors during development
}
return event;
},
});
// => Wrap root component with Sentry error boundary
export default Sentry.wrap(App);
// => Sentry.wrap: catches unhandled errors at React tree level
// => Manual error capture
function processPayment(amount: number) {
try {
// ... payment logic
} catch (error) {
Sentry.captureException(error, {
tags: { component: "payment", currency: "IDR" },
extra: { amount },
// => tags: searchable key-value in Sentry
// => extra: additional context (not searchable)
});
throw error; // => re-throw after capturing
}
}
// => Performance monitoring
const transaction = Sentry.startTransaction({ name: "load-products" });
// => manual transaction: track custom operations
const span = transaction.startChild({ op: "api.fetch", description: "GET /products" });
// => spans: child operations within a transaction
const products = await fetchProducts();
span.finish();
transaction.finish();
// => visible in Sentry Performance tab with timing breakdownKey Takeaway: Initialize Sentry with dsn, tracesSampleRate, and React Navigation instrumentation. Wrap the root component with Sentry.wrap() for unhandled error capture. Source maps uploaded by EAS Build enable human-readable stack traces.
Why It Matters: Production React Native apps crash silently without crash reporting — you learn about crashes from 1-star App Store reviews, not telemetry. Sentry with source maps shows the exact TypeScript line that caused a crash, including the function name and file path — not minified gibberish. tracesSampleRate: 0.2 captures performance data without the cost of 100% tracing. Sentry's performance monitoring identifies slow API calls, long navigation transitions, and N+1 query patterns that degrade user experience without causing outright crashes. Most production React Native apps set up Sentry before public launch.
Example 83: Expo Modules API — Swift/Kotlin Native Module
The Expo Modules API provides a higher-level alternative to TurboModules for writing custom native modules with TypeScript-like conventions.
// ios/ExampleModule.swift
import ExpoModulesCore
// => expo-modules-core: the Expo Modules API runtime
public class ExampleModule: Module {
// => Module: base class for Expo modules
public func definition() -> ModuleDefinition {
// => definition(): declares the module's API (called at startup)
Name("ExampleModule")
// => Name: registered name for NativeModules.ExampleModule
Function("computeHash") { (input: String) -> String in
// => Function: exposes a synchronous function to JS
// => input: String — TypeScript string is auto-mapped to Swift String
// => returns String — Swift String auto-mapped to TypeScript string
var hash = 0
for char in input.unicodeScalars {
hash = (hash &* 31) &+ Int(char.value)
// => &*: overflow-safe multiplication (Swift Int can overflow)
}
return String(hash, radix: 16) // => hex string
}
AsyncFunction("fetchDeviceInfo") { () -> [String: String] in
// => AsyncFunction: returns Promise to JS (runs on background thread)
// => [String: String] dict auto-mapped to TypeScript Record<string, string>
return [
"model": UIDevice.current.model,
"systemVersion": UIDevice.current.systemVersion,
"name": UIDevice.current.name,
]
}
Events("onDataChanged")
// => Events: declares events this module can emit
// => In JS: module.addListener('onDataChanged', callback)
}
}// JavaScript usage
import { requireNativeModule, EventEmitter } from "expo-modules-core";
const ExampleModule = requireNativeModule("ExampleModule");
// => requireNativeModule: typed access to the native module
const hash = ExampleModule.computeHash("hello world");
// => synchronous: returns '461fb5d8' (hex hash)
const info = await ExampleModule.fetchDeviceInfo();
// => async: returns { model: 'iPhone', systemVersion: '18.4', name: 'Fatima's iPhone' }
// Event listening
const emitter = new EventEmitter(ExampleModule);
const sub = emitter.addListener("onDataChanged", (data) => {
console.log("Data changed:", data);
});
// => cleanup: sub.remove() in useEffect cleanupKey Takeaway: Expo Modules API uses Module base class and ModuleDefinition DSL. Function for synchronous, AsyncFunction for async, Events for event emitters. Higher-level than TurboModules, integrates with Expo's build system automatically.
Why It Matters: The Expo Modules API is the easiest way to write native modules for Expo-managed projects — no Podfile configuration, no Gradle changes, no Codegen spec file. The Function/AsyncFunction DSL eliminates the boilerplate of TurboModule specs and generated code. Type conversion between Swift/Kotlin types and TypeScript types is automatic. For teams using Expo managed workflow who need custom native functionality (hardware APIs, platform services, proprietary SDKs), Expo Modules are the recommended starting point before evaluating Nitro Modules for performance-critical hot paths.
Example 84: useReducer + useContext — Complex Local State
useReducer with useContext provides a Redux-like pattern for complex local state that spans multiple child components without a global store.
import { createContext, useContext, useReducer, useCallback, ReactNode } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
type Step = 'account' | 'profile' | 'payment' | 'review';
type WizardState = {
currentStep: Step;
data: {
account?: { email: string; password: string };
profile?: { name: string; bio: string };
payment?: { cardLast4: string };
};
errors: Partial<Record<Step, string>>;
completed: Set<Step>;
};
type WizardAction =
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'SET_DATA'; step: Step; data: WizardState['data'][Step] }
| { type: 'SET_ERROR'; step: Step; message: string }
| { type: 'COMPLETE_STEP'; step: Step };
// => discriminated union: TypeScript narrows action.data based on action.type
const STEPS: Step[] = ['account', 'profile', 'payment', 'review'];
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case 'NEXT_STEP': {
const currentIndex = STEPS.indexOf(state.currentStep);
const nextStep = STEPS[currentIndex + 1] ?? state.currentStep;
return { ...state, currentStep: nextStep };
// => spread: immutable update (never mutate state directly)
}
case 'PREV_STEP': {
const currentIndex = STEPS.indexOf(state.currentStep);
const prevStep = STEPS[currentIndex - 1] ?? state.currentStep;
return { ...state, currentStep: prevStep };
}
case 'SET_DATA':
return { ...state, data: { ...state.data, [action.step]: action.data } };
// => nested immutable update: spread outer, set key
case 'COMPLETE_STEP':
return { ...state, completed: new Set([...state.completed, action.step]) };
default:
return state;
}
}
const WizardContext = createContext<{
state: WizardState;
dispatch: React.Dispatch<WizardAction>;
} | null>(null);
function WizardProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(wizardReducer, {
currentStep: 'account',
data: {},
errors: {},
completed: new Set(),
});
return (
<WizardContext.Provider value={{ state, dispatch }}>
{children}
</WizardContext.Provider>
);
}
function useWizard() {
const context = useContext(WizardContext);
if (!context) throw new Error('useWizard must be used within WizardProvider');
// => Guard: prevents using the hook outside the provider tree
return context;
}
function StepIndicator() {
const { state } = useWizard();
return (
<View style={styles.steps}>
{STEPS.map((step) => (
<View key={step} style={[styles.step, state.currentStep === step && styles.activeStep, state.completed.has(step) && styles.completedStep]}>
<Text style={styles.stepText}>{step}</Text>
</View>
))}
</View>
);
}
export default function RegistrationWizard() {
return (
<WizardProvider>
<View style={styles.container}>
<StepIndicator />
{/* => StepContent, StepNavigation components use useWizard() */}
</View>
</WizardProvider>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
steps: { flexDirection: 'row', gap: 8, marginBottom: 24 },
step: { flex: 1, padding: 8, backgroundColor: '#f0f0f0', borderRadius: 6, alignItems: 'center' },
activeStep: { backgroundColor: '#0173B2' },
completedStep: { backgroundColor: '#029E73' },
stepText: { fontSize: 11, fontWeight: '600', color: '#333' },
});Key Takeaway: Combine useReducer (for complex state transitions) with useContext (for sharing state across deep component trees) to create a typed, testable local state container. Use discriminated union action types for exhaustive switch statements.
Why It Matters: Registration wizards, multi-step forms, and checkout flows have complex state that doesn't belong in a global Zustand store (it's only relevant while the wizard is open) but is too complex for simple useState. useReducer centralizes state transitions in a pure function that is easily unit-tested independently of the UI. The discriminated union action type makes every state transition explicit and TypeScript-validated — adding a new action type that isn't handled in the reducer is a compile error. This pattern scales from 3-step wizards to complex editor UIs with dozens of state transitions.
Example 85: Production Checklist
The complete production readiness checklist covering New Architecture, Hermes, performance, accessibility, and store compliance.
// Production readiness validation script (run before release)
const checklist = {
// => 1. New Architecture (mandatory since RN 0.82)
newArchitecture: {
check: "New Architecture enabled (default in Expo SDK 55)",
verify: 'app.json: "newArchEnabled": true (or SDK 55 default)',
impact: "BLOCKER: Legacy bridge removed in RN 0.82",
},
// => 2. Hermes v1 (opt-in in SDK 55)
hermes: {
check: "Hermes v1 engine enabled",
verify: 'app.json: "jsEngine": "hermes"',
impact: "15-30% faster startup, 75% smaller OTA updates",
},
// => 3. StyleSheet.absoluteFill (RN 0.85 breaking change)
absoluteFill: {
check: "No StyleSheet.absoluteFillObject usage (removed in 0.85)",
verify: 'grep -r "absoluteFillObject" src/ → expect 0 results',
impact: "BLOCKER: Runtime error if found",
},
// => 4. Error Tracking
sentry: {
check: "Sentry initialized with DSN and source maps",
verify: "Sentry.init() called before app render",
impact: "Blind to production crashes without this",
},
// => 5. Performance
performance: {
checks: [
"FlatList items wrapped in React.memo()",
"renderItem and keyExtractor wrapped in useCallback()",
"No anonymous objects in styles (use StyleSheet.create)",
"Images use expo-image with blurhash placeholders",
"Heavy screens use FlashList instead of FlatList (>100 items)",
],
},
// => 6. Accessibility (WCAG AA)
accessibility: {
checks: [
"All Pressable/TouchableOpacity have accessibilityLabel",
"Icons without visible text have accessibilityLabel",
"Color is not the only differentiator (add text or icons)",
"Text minimum size: 14pt (readable without system zoom)",
"Tap targets minimum 44pt × 44pt (use hitSlop if smaller)",
"Dynamic text sizes supported (useWindowDimensions for fonts)",
],
},
// => 7. iOS Privacy Manifest (iOS 17+, mandatory May 2024)
privacyManifest: {
check: "PrivacyInfo.xcprivacy present with all accessed API reasons",
verify: "ios/MyApp/PrivacyInfo.xcprivacy or app.json privacyManifests",
impact: "BLOCKER: App Store rejection since May 2024",
},
// => 8. EAS Build configured
easBuild: {
check: "eas.json has development, preview, production profiles",
verify: "eas build --profile production --dry-run",
},
// => 9. OTA Update channel configured
easUpdate: {
check: "EAS Update channels match eas.json build profiles",
verify: "eas update:view --channel production",
},
// => 10. Notification permissions (Android 13+)
notifications: {
check: "POST_NOTIFICATIONS in Android permissions array",
verify: "app.json android.permissions includes POST_NOTIFICATIONS",
impact: "Push notifications silently fail on Android 13+ without this",
},
};
// => Pre-release command sequence
const releaseCommands = [
"npx tsc --noEmit", // => TypeScript type check
"npm run lint", // => ESLint
"npm test -- --forceExit", // => Jest unit tests
"npx detox test -c ios.sim.release", // => Detox E2E on simulator
"eas build --platform all --profile production --non-interactive", // => Build binaries
'eas update --channel production --message "v2.5.0 release"', // => OTA update
"eas submit --platform all --latest", // => Submit to stores
];Key Takeaway: Production readiness requires New Architecture enabled, Hermes v1, no deprecated APIs (absoluteFillObject removed in 0.85), Sentry crash reporting, performance-optimized lists, WCAG AA accessibility, iOS privacy manifests, and complete EAS pipeline.
Why It Matters: The production checklist codifies the collective lessons from thousands of React Native app launches. Each item represents a real failure mode: apps rejected by Apple (missing privacy manifest), apps with zero crash visibility (no Sentry), apps with 1-star reviews for janky scrolling (unoptimized FlatList), apps silently failing to send notifications (missing Android 13 permission). Running through this list before every major release prevents the most common post-launch incidents. The absoluteFillObject removal in RN 0.85 is a particularly sneaky breaking change — code that worked in 0.84 crashes at runtime in 0.85 with no compile-time warning. Automating this check with grep in CI prevents the runtime surprise in production.
Last updated April 28, 2026