Merge pull request #33 from sonartype3/feat/add_react_native_skill

feat: add react-native skill
This commit is contained in:
zest0198
2026-03-26 20:38:46 +08:00
committed by GitHub
14 changed files with 3040 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ Development skills for AI coding agents. Plug into your favorite AI coding tool
| `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official | | `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official |
| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official | | `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official |
| `flutter-dev` | Flutter cross-platform development covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and testing strategies. | Official | | `flutter-dev` | Flutter cross-platform development covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and testing strategies. | Official |
| `react-native-dev` | React Native and Expo development guide covering components, styling, animations, navigation, state management, forms, networking, performance optimization, testing, native capabilities, and engineering (project structure, deployment, SDK upgrades, CI/CD). | Official |
| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official | | `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official |
| `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official | | `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official |
| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official | | `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official |

View File

@@ -15,6 +15,7 @@
| `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍WCAG、构建问题排查、性能优化与动效系统。 | Official | | `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍WCAG、构建问题排查、性能优化与动效系统。 | Official |
| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图符合 Apple HIG 规范。 | Official | | `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图符合 Apple HIG 规范。 | Official |
| `flutter-dev` | Flutter 跨平台开发指南,涵盖 Widget 模式、Riverpod/Bloc 状态管理、GoRouter 导航、性能优化与测试策略。 | Official | | `flutter-dev` | Flutter 跨平台开发指南,涵盖 Widget 模式、Riverpod/Bloc 状态管理、GoRouter 导航、性能优化与测试策略。 | Official |
| `react-native-dev` | React Native 与 Expo 开发指南涵盖组件、样式、动画、导航、状态管理、表单、网络请求、性能优化、测试、原生能力及工程化项目结构、部署、SDK 升级、CI/CD。 | Official |
| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official | | `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official |
| `gif-sticker-maker` | 将照片人物、宠物、物品、Logo转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official | | `gif-sticker-maker` | 将照片人物、宠物、物品、Logo转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式CREATE从零生成15 种封面风格、FILL填写现有表单字段、REFORMAT将已有文档重排为新设计。排版与配色由文档类型自动推导输出即可打印。 | Official | | `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式CREATE从零生成15 种封面风格、FILL填写现有表单字段、REFORMAT将已有文档重排为新设计。排版与配色由文档类型自动推导输出即可打印。 | Official |

View File

@@ -0,0 +1,149 @@
---
name: react-native-dev
description: |
React Native and Expo development guide covering components, styling, animations, navigation,
state management, forms, networking, performance optimization, testing, native capabilities,
and engineering (project structure, deployment, SDK upgrades, CI/CD).
Use when: building React Native or Expo apps, implementing animations or native UI, managing
state, fetching data, writing tests, optimizing performance, deploying to App Store/Play Store,
setting up CI/CD, upgrading Expo SDK, or configuring Tailwind/NativeWind.
license: MIT
metadata:
version: "1.0.0"
category: mobile
sources:
- Expo documentation (docs.expo.dev)
- React Native documentation (reactnative.dev)
- EAS (Expo Application Services) documentation
---
# React Native & Expo Development Guide
A practical guide for building production-ready React Native and Expo applications. Covers UI, animations, state, testing, performance, and deployment.
## References
Consult these resources as needed:
- [references/navigation.md](references/navigation.md) — Expo Router: Stack, Tabs, NativeTabs (`headerLargeTitle`, `headerBackButtonDisplayMode`), links, modals, sheets, context menus
- [references/components.md](references/components.md) — FlashList patterns, `expo-image`, safe areas (`contentInsetAdjustmentBehavior`), native controls, blur/glass effects, storage
- [references/styling.md](references/styling.md) — StyleSheet, NativeWind/Tailwind, platform styles, theming, dark mode
- [references/animations.md](references/animations.md) — Reanimated 3: entering/exiting, shared values, gestures, scroll-driven
- [references/state-management.md](references/state-management.md) — Zustand (selectors, persist), Jotai (atoms, derived), React Query, Context
- [references/forms.md](references/forms.md) — React Hook Form + Zod: validation, multi-step, dynamic arrays
- [references/networking.md](references/networking.md) — fetch wrapper, React Query (optimistic updates), auth tokens, offline, API routes, webhooks
- [references/performance.md](references/performance.md) — Profiling workflow, FlashList + `memo`, bundle analysis, TTI, memory leaks, animation perf
- [references/testing.md](references/testing.md) — Jest, React Native Testing Library, E2E with Maestro
- [references/native-capabilities.md](references/native-capabilities.md) — Camera, location, permissions (`use*Permissions` hooks), haptics, notifications, biometrics
- [references/engineering.md](references/engineering.md) — Project layout (`components/ui/`, `stores/`, `services/`), path aliases, SDK upgrades, EAS build/submit, CI/CD, DOM components
## Quick Reference
### Component Preferences
| Purpose | Use | Instead of |
|---------|-----|------------|
| Lists | `FlashList` (`@shopify/flash-list`) + `memo` items | `FlatList` (no view recycling) |
| Images | `expo-image` | RN `<Image>` (no cache, no WebP) |
| Press | `Pressable` | `TouchableOpacity` (legacy) |
| Audio | `expo-audio` | `expo-av` (deprecated) |
| Video | `expo-video` | `expo-av` (deprecated) |
| Animations | Reanimated 3 | RN Animated API (limited) |
| Gestures | Gesture Handler | PanResponder (legacy) |
| Platform check | `process.env.EXPO_OS` | `Platform.OS` |
| Context | `React.use()` | `React.useContext()` (React 18) |
| Safe area scroll | `contentInsetAdjustmentBehavior="automatic"` | `<SafeAreaView>` |
| SF Symbols | `expo-image` with `source="sf:name"` | `expo-symbols` |
### Scaling Up
| Situation | Consider |
|-----------|----------|
| Long lists with scroll jank | Virtualized list libraries (e.g. FlashList) |
| Want Tailwind-style classes | NativeWind v4 |
| High-frequency storage reads | Sync-based storage (e.g. MMKV) |
| New project with Expo | Expo Router over bare React Navigation |
### State Management
| State Type | Solution |
|------------|----------|
| Local UI state | `useState` / `useReducer` |
| Shared app state | Zustand or Jotai |
| Server / async data | React Query |
| Form state | React Hook Form + Zod |
### Performance Priorities
| Priority | Issue | Fix |
|----------|-------|-----|
| CRITICAL | Long list jank | `FlashList` + memoized items |
| CRITICAL | Large bundle | Avoid barrel imports, enable R8 |
| HIGH | Too many re-renders | Zustand selectors, React Compiler |
| HIGH | Slow startup | Disable bundle compression, native nav |
| MEDIUM | Animation drops | Only animate `transform`/`opacity` |
## New Project Init
```bash
# 1. Create project
npx create-expo-app@latest my-app --template blank-typescript
cd my-app
# 2. Install Expo Router + core deps
npx expo install expo-router react-native-safe-area-context react-native-screens
# 3. (Optional) Common extras
npx expo install expo-image react-native-reanimated react-native-gesture-handler
```
Then configure:
1. Set entry point in `package.json`: `"main": "expo-router/entry"`
2. Add scheme in `app.json`: `"scheme": "my-app"`
3. Delete `App.tsx` and `index.ts`
4. Create `app/_layout.tsx` as root Stack layout
5. Create `app/(tabs)/_layout.tsx` for tab navigation
6. Create route files in `app/(tabs)/` (see [navigation.md](references/navigation.md))
For web support, also install: `npx expo install react-native-web react-dom @expo/metro-runtime`
## Core Principles
**Consult references before writing**: when implementing navigation, lists, networking, or project setup, read the matching reference file above for patterns and pitfalls.
**Try Expo Go first** (`npx expo start`). Custom builds (`eas build`) only needed when using local Expo modules, Apple targets, or third-party native modules not in Expo Go.
**Conditional rendering**: use `{count > 0 && <Text />}` not `{count && <Text />}` (renders "0").
**Animation rule**: only animate `transform` and `opacity` — GPU-composited, no layout thrash.
**Imports**: always import directly from source, not barrel files — avoids bundle bloat.
**Lists and images**: before using `FlatList` or RN `Image`, check the Component Preferences table above — `FlashList` and `expo-image` are almost always the right choice.
**Route files**: always use kebab-case, never co-locate components/types/utils in `app/`.
## Checklist
### New Project Setup
- [ ] `tsconfig.json` path aliases configured
- [ ] `EXPO_PUBLIC_API_URL` env var set per environment
- [ ] Root layout has `GestureHandlerRootView` (if using gestures)
- [ ] `contentInsetAdjustmentBehavior="automatic"` on all scroll views
- [ ] `FlashList` instead of `FlatList` for lists > 20 items
### Before Shipping
- [ ] Profile in `--profile` mode, fix frames > 16ms
- [ ] Bundle analyzed (`source-map-explorer`), no barrel imports
- [ ] R8 enabled for Android
- [ ] Unit + component tests for critical paths
- [ ] E2E flows for login, core feature, checkout
---
Flutter development → see `flutter-dev` skill.
iOS native (UIKit/SwiftUI) → see `ios-application-dev` skill.
Android native (Kotlin/Compose) → see `android-native-dev` skill.
*React Native is a trademark of Meta Platforms, Inc. Expo is a trademark of 650 Industries, Inc. All other product names are trademarks of their respective owners.*

View File

@@ -0,0 +1,254 @@
# Animations Reference
Reanimated 3 animations, gestures, and transitions for Expo/React Native.
## Core Rules
- **Only animate `transform` and `opacity`** — GPU-composited, no layout recalculation
- Use `useDerivedValue` for computed animated values, not inline JS expressions
- Use `Gesture.Tap` instead of `Pressable` inside `GestureDetector`
- All Reanimated callbacks run as worklets on the UI thread — no async/await
## Setup
```bash
npx expo install react-native-reanimated react-native-gesture-handler
```
```js
// babel.config.js
module.exports = { presets: ["babel-preset-expo"], plugins: ["react-native-reanimated/plugin"] };
```
```tsx
// app/_layout.tsx — wrap root in GestureHandlerRootView
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function RootLayout() {
return <GestureHandlerRootView style={{ flex: 1 }}><Stack /></GestureHandlerRootView>;
}
```
## Entering / Exiting Animations
```tsx
import Animated, {
FadeIn, FadeOut,
SlideInRight, SlideOutLeft,
ZoomIn, ZoomOut,
BounceIn,
} from "react-native-reanimated";
// Basic
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Text>Content</Text>
</Animated.View>
// With options
<Animated.View
entering={FadeIn.duration(300).delay(100)}
exiting={SlideOutLeft.duration(200)}
/>
// Spring-based
<Animated.View entering={ZoomIn.springify().damping(15)} />
```
### Built-in Presets
| Category | Entering | Exiting |
|----------|----------|---------|
| Fade | `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight` | `FadeOut*` |
| Slide | `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight` | `SlideOut*` |
| Zoom | `ZoomIn`, `ZoomInUp`, `ZoomInDown` | `ZoomOut*` |
| Bounce | `BounceIn`, `BounceInUp`, `BounceInDown` | `BounceOut*` |
| Flip | `FlipInXUp`, `FlipInYLeft` | `FlipOut*` |
| Roll | `RollInLeft`, `RollInRight` | `RollOut*` |
| Stretch | `StretchInX`, `StretchInY` | `StretchOut*` |
| Pinwheel | `PinwheelIn` | `PinwheelOut` |
| Rotate | `RotateInDownLeft` | `RotateOut*` |
| LightSpeed | `LightSpeedInLeft` | `LightSpeedOut*` |
## Shared Values & useAnimatedStyle
```tsx
import {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
withRepeat,
withSequence,
Easing,
} from "react-native-reanimated";
const offset = useSharedValue(0);
const opacity = useSharedValue(1);
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
opacity: opacity.value,
}));
// Animate
offset.value = withSpring(100);
opacity.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad) });
// Repeat
opacity.value = withRepeat(withTiming(0.3, { duration: 800 }), -1, true);
// Sequence
offset.value = withSequence(
withTiming(-10, { duration: 100 }),
withTiming(10, { duration: 100 }),
withSpring(0),
);
<Animated.View style={animStyle} />
```
## useDerivedValue
```tsx
import { useDerivedValue } from "react-native-reanimated";
const progress = useSharedValue(0); // 01
const rotation = useDerivedValue(() => `${progress.value * 360}deg`);
const scale = useDerivedValue(() => 0.5 + progress.value * 0.5);
const animStyle = useAnimatedStyle(() => ({
transform: [{ rotate: rotation.value }, { scale: scale.value }],
}));
```
## Layout Animations
```tsx
import { Layout, LinearTransition, CurvedTransition } from "react-native-reanimated";
// Item reorder/add/remove animation
<Animated.View layout={LinearTransition}>
{/* Content that changes size/position */}
</Animated.View>
// Spring layout transition
<Animated.View layout={LinearTransition.springify()}>
```
## Gestures
```tsx
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
// Pan gesture
const offsetX = useSharedValue(0);
const offsetY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onUpdate((e) => {
offsetX.value = e.translationX;
offsetY.value = e.translationY;
})
.onEnd(() => {
offsetX.value = withSpring(0);
offsetY.value = withSpring(0);
});
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }],
}));
<GestureDetector gesture={panGesture}>
<Animated.View style={animStyle} />
</GestureDetector>
```
```tsx
// Tap gesture (use instead of Pressable inside GestureDetector)
const tapGesture = Gesture.Tap()
.numberOfTaps(1)
.onEnd(() => {
scale.value = withSequence(withTiming(0.95), withSpring(1));
});
// Pinch gesture
const baseScale = useSharedValue(1);
const savedScale = useSharedValue(1);
const pinchGesture = Gesture.Pinch()
.onUpdate((e) => { baseScale.value = savedScale.value * e.scale; })
.onEnd(() => { savedScale.value = baseScale.value; });
// Composed gestures
const composed = Gesture.Simultaneous(panGesture, pinchGesture);
const exclusive = Gesture.Exclusive(tapGesture, panGesture);
```
## Scroll-Driven Animations
```tsx
import Animated, {
useAnimatedScrollHandler,
useSharedValue,
interpolate,
Extrapolation,
} from "react-native-reanimated";
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y;
});
// Parallax header
const headerStyle = useAnimatedStyle(() => ({
transform: [{
translateY: interpolate(scrollY.value, [0, 200], [0, -100], Extrapolation.CLAMP),
}],
opacity: interpolate(scrollY.value, [0, 200], [1, 0], Extrapolation.CLAMP),
}));
<Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}>
<Animated.View style={headerStyle}>
<Text>Parallax Header</Text>
</Animated.View>
</Animated.ScrollView>
```
## Zoom Transitions (Expo Router, iOS 18+)
```tsx
import { Link } from "expo-router";
<Link href="/detail" asChild>
<Link.AppleZoom>
<Pressable>
<Image source={thumbnail} />
</Pressable>
</Link.AppleZoom>
</Link>
```
## Adding Animations to State Changes
```tsx
// ✓ Always add entering/exiting for state-driven UI changes
{isVisible && (
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
<Toast message={message} />
</Animated.View>
)}
// ✓ AnimatedFlatList for list item changes
import Animated from "react-native-reanimated";
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
```
## Common Mistakes
| Wrong | Right |
|-------|-------|
| Animate `width`/`height` | Animate `transform: scaleX/scaleY` |
| Inline JS math in `useAnimatedStyle` | `useDerivedValue` for computations |
| `Pressable` inside `GestureDetector` | `Gesture.Tap()` |
| `async` in worklet | Run async outside, update sharedValue in callback |
| Frequent `console.log` in worklet | `console.log` works but serializes to JS thread — use sparingly in hot paths |

View File

@@ -0,0 +1,124 @@
# Components Reference
Native UI components, media, visual effects, and storage patterns for Expo/React Native.
## Images
```tsx
import { Image } from "expo-image";
// Always use expo-image — not React Native's built-in Image
<Image
source={{ uri: "https://example.com/photo.jpg" }}
style={{ width: 200, height: 200 }}
contentFit="cover"
transition={300}
placeholder={blurhash}
/>
```
## Lists
```tsx
import { FlashList } from "@shopify/flash-list";
import { memo } from "react";
const Item = memo(({ title }: { title: string }) => (
<View style={styles.item}><Text>{title}</Text></View>
));
<FlashList
data={items}
renderItem={({ item }) => <Item title={item.title} />}
keyExtractor={(item) => item.id}
estimatedItemSize={80}
/>
```
## Safe Areas
```tsx
import { useSafeAreaInsets } from "react-native-safe-area-context";
// With ScrollView
<ScrollView contentInsetAdjustmentBehavior="automatic">
{/* content */}
</ScrollView>
// Manual insets
const insets = useSafeAreaInsets();
<View style={{ paddingBottom: insets.bottom }} />
```
## Native Controls (iOS)
```tsx
import { Switch } from "react-native";
import SegmentedControl from "@react-native-segmented-control/segmented-control";
// Switch
<Switch value={enabled} onValueChange={setEnabled} />
// Segmented Control
<SegmentedControl
values={["Day", "Week", "Month"]}
selectedIndex={selectedIndex}
onChange={(e) => setSelectedIndex(e.nativeEvent.selectedSegmentIndex)}
/>
```
## Form Sheets (Bottom Sheet)
```tsx
// app/modal.tsx
import { Stack } from "expo-router";
<Stack.Screen options={{
presentation: "formSheet",
sheetAllowedDetents: [0.5, 1.0],
sheetGrabberVisible: true,
}} />
```
## Visual Effects
```tsx
import { BlurView } from "expo-blur";
<BlurView intensity={80} tint="light" style={StyleSheet.absoluteFill} />
// Liquid glass (iOS 26+, New Architecture only)
import { GlassEffect } from "expo-glass-effect";
<GlassEffect style={{ borderRadius: 16, padding: 20 }} />
```
## Search
```tsx
// Using expo-router search bar (iOS only)
import { useNavigation } from "expo-router";
useEffect(() => {
navigation.setOptions({
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e) => setQuery(e.nativeEvent.text),
},
});
}, [navigation]);
```
## Storage
| Need | Solution |
|------|----------|
| Structured data | `expo-sqlite` |
| Simple key-value | `@react-native-async-storage/async-storage` |
| Sensitive data | `expo-secure-store` |
## Media
```tsx
import { CameraView, useCameraPermissions } from "expo-camera";
import { useAudioPlayer } from "expo-audio";
import { useVideoPlayer, VideoView } from "expo-video";
import * as ImagePicker from "expo-image-picker";
```

View File

@@ -0,0 +1,527 @@
# Engineering Reference
Project structure, tooling, builds, releases, and platform integration for Expo / React Native.
## Project Structure
### Standard Layout
```
my-app/
app/ File-based routing (Expo Router)
_layout.tsx Root layout: providers, fonts, NativeTabs
index.tsx → /
(tabs)/
_layout.tsx Tab navigator
home.tsx → /home
profile.tsx → /profile
(auth)/
login.tsx → /login (group, not in URL)
register.tsx → /register
user/
[id].tsx → /user/:id
[id]/
posts.tsx → /user/:id/posts
api/
users+api.ts → /api/users (server route)
users/[id]+api.ts → /api/users/:id
components/ Reusable UI components
ui/ Primitive components (Button, Input, Card)
shared/ Composed components (UserAvatar, PostCard)
hooks/ Custom React hooks
stores/ Zustand / Jotai stores
services/ API client, external service wrappers
utils/ Pure utility functions
constants/ App-wide constants (colors, spacing, config)
types/ Shared TypeScript types/interfaces
assets/ Static assets (images, fonts, icons)
scripts/ Build/dev helper scripts
app.json Expo config
eas.json EAS Build config
tsconfig.json TypeScript config with path aliases
.env Environment variables
.env.development
.env.production
```
### Route Conventions
| File | Route | Notes |
|------|-------|-------|
| `app/index.tsx` | `/` | Home/root |
| `app/about.tsx` | `/about` | Static route |
| `app/user/[id].tsx` | `/user/:id` | Dynamic segment |
| `app/user/[...rest].tsx` | `/user/*` | Catch-all |
| `app/(tabs)/home.tsx` | `/home` | Group (not in URL) |
| `app/(a,b)/shared.tsx` | Shared between tabs `a` and `b` | Multi-group |
| `app/_layout.tsx` | Layout wrapper | No route |
| `app/+not-found.tsx` | 404 page | |
| `app/api/users+api.ts` | `/api/users` | Server route |
**Rules**:
- Routes only in `app/` — no components, types, or utils
- Always have a route matching `/`
- Use kebab-case filenames (`user-profile.tsx`, not `UserProfile.tsx`)
- Remove old route files when restructuring
### Path Aliases
```json
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"],
"@components/*": ["./components/*"],
"@hooks/*": ["./hooks/*"],
"@stores/*": ["./stores/*"],
"@services/*": ["./services/*"],
"@utils/*": ["./utils/*"],
"@constants/*": ["./constants/*"],
"@types/*": ["./types/*"]
}
}
}
```
```tsx
// ✗ Relative imports — fragile, change with file moves
import { Button } from "../../../components/ui/Button";
// ✓ Alias imports — stable
import { Button } from "@components/ui/Button";
```
Metro resolves `paths` and `baseUrl` from `tsconfig.json` natively — no extra config needed. If using a non-Metro bundler, install `babel-plugin-module-resolver`:
```js
// babel.config.js — only needed for non-Metro bundlers
module.exports = {
presets: ["babel-preset-expo"],
plugins: [
["module-resolver", {
root: ["./"],
alias: {
"@": "./",
"@components": "./components",
"@hooks": "./hooks",
"@stores": "./stores",
"@services": "./services",
},
}],
],
};
```
### Components Organization
```
components/
ui/ Atomic components
Button.tsx
Input.tsx
Card.tsx
Badge.tsx
index.ts Barrel export
shared/ Composed components
UserAvatar.tsx
PostCard.tsx
EmptyState.tsx
layout/ Layout components
Screen.tsx SafeArea wrapper
Header.tsx
```
```tsx
// components/ui/index.ts — barrel export
export { Button } from "./Button";
export { Input } from "./Input";
export { Card } from "./Card";
// Usage
import { Button, Input, Card } from "@components/ui";
```
### Design System
```
constants/
colors.ts Color palette + semantic colors
spacing.ts 8pt grid spacing values
typography.ts Font families, sizes, weights
theme.ts Combined theme object
```
```tsx
// constants/colors.ts
export const colors = {
primary: "#6200EE",
secondary: "#03DAC6",
background: "#FFFFFF",
surface: "#F5F5F5",
error: "#B00020",
text: { primary: "#000000DE", secondary: "#0000008A" },
} as const;
// constants/spacing.ts — 8pt grid
export const spacing = {
xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48,
} as const;
// constants/typography.ts
export const typography = {
sizes: { xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 },
weights: { regular: "400", medium: "500", semibold: "600", bold: "700" },
} as const;
```
### Services Layer
```
services/
api/
client.ts Base fetch client with auth headers
users.ts User-related API calls
posts.ts Post-related API calls
storage/
secure-store.ts Wrapper for expo-secure-store
async-storage.ts Wrapper for AsyncStorage
notifications/
push.ts Expo push notification helpers
```
```tsx
// services/api/client.ts
const BASE_URL = process.env.EXPO_PUBLIC_API_URL!;
export const api = {
get: <T,>(path: string, token?: string) =>
fetch(`${BASE_URL}${path}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}).then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
}),
// post/put/delete follow same pattern — add method, Content-Type, JSON.stringify(body)
};
```
### Monorepo
```
my-monorepo/
apps/
mobile/ Expo app (all native deps here)
package.json
app.json
web/ Next.js app
package.json
packages/
ui/ Shared UI components (no native deps)
package.json
utils/ Shared utilities (no native deps)
package.json
types/ Shared TypeScript types
package.json
package.json Root workspace config
```
```json
// Root package.json
{
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"mobile": "yarn workspace @my/mobile start",
"web": "yarn workspace @my/web dev"
}
}
```
**Monorepo rules**:
- **Keep native dependencies in the app package** (`apps/mobile`) — never in shared packages
- Use a single version of each dependency across all packages
- Shared packages should be pure JS/TS only
### Environment Variables
```bash
# .env (committed, non-sensitive defaults)
EXPO_PUBLIC_APP_NAME=MyApp
EXPO_PUBLIC_API_VERSION=v1
# .env.development (local only, gitignored)
EXPO_PUBLIC_API_URL=http://localhost:3000
# .env.production (CI/CD only, gitignored)
EXPO_PUBLIC_API_URL=https://api.production.example.com
```
```tsx
// types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_APP_NAME: string;
}
}
}
export {};
```
### Custom Fonts
```bash
npx expo install expo-font
```
```json
// app.json — config plugin (preferred over manual linking)
{
"expo": {
"plugins": [
["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }]
]
}
}
```
```tsx
// app/_layout.tsx
import { useFonts } from "expo-font";
import { SplashScreen } from "expo-router";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({ "Inter-Regular": require("../assets/fonts/Inter-Regular.ttf") });
useEffect(() => { if (loaded) SplashScreen.hideAsync(); }, [loaded]);
if (!loaded) return null;
return <Stack />;
}
```
## Development Builds
Expo Go (`npx expo start`) covers most use cases out of the box. Switch to a custom dev client when your project uses native code that Expo Go doesn't bundle — for example, a local Expo module in `modules/`, an Apple target (widget, app clip), or a community native library that isn't pre-installed in Expo Go.
### Creating a Dev Client
```bash
# Option A — cloud build, push to TestFlight / internal distribution
eas build -p ios --profile development --submit
# Option B — build locally (requires Xcode / Android Studio)
eas build -p ios --profile development --local
```
After installing on the device or simulator, connect with:
```bash
npx expo start --dev-client
```
### eas.json Profile
```json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"autoIncrement": true
}
}
}
```
## Upgrading the SDK
### Routine Upgrade
```bash
npx expo install expo@latest --fix # bumps Expo + aligns peer deps
npx expo-doctor # surfaces remaining mismatches
```
Then test on both platforms and rebuild the dev client if you use one.
### Trying a Pre-release
Pre-release versions are tagged `@next` on npm:
```bash
npx expo install expo@next --fix
```
### Notable Changes Across SDK Versions
| Version | What Changed |
|---------|-------------|
| SDK 53 | New Architecture on by default; Expo Go requires it; `autoprefixer` no longer needed |
| SDK 54 | React 19 (`use()` replaces `useContext`, `<Context>` replaces `<Context.Provider>`, `forwardRef` removed); React Compiler available; `EXPO_USE_FAST_RESOLVER` removed |
| SDK 55 | NativeTabs API updated — Icon/Label/Badge accessed via `NativeTabs.Trigger.*` |
| Ongoing | `expo-av` deprecated in favor of `expo-audio` + `expo-video` |
### React 19 Patterns (SDK 54+)
```tsx
// Context
import { use, createContext } from "react";
const ThemeCtx = createContext("light");
// consume: const theme = use(ThemeCtx);
// provide: <ThemeCtx value="dark">...</ThemeCtx>
// Refs — no more forwardRef
function Field({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
return <TextInput ref={ref} {...props} />;
}
```
### Opting Out of New Architecture
If a third-party library breaks under the New Architecture:
```json
{ "expo": { "newArchEnabled": false } }
```
Check compatibility at [reactnative.directory](https://reactnative.directory).
## Releasing
### Build Profiles
A typical `eas.json` has three tiers:
```json
{
"cli": { "version": ">= 16.0.1", "appVersionSource": "remote" },
"build": {
"development": { "developmentClient": true, "distribution": "internal", "autoIncrement": true },
"preview": { "distribution": "internal", "autoIncrement": true },
"production": { "autoIncrement": true, "ios": { "resourceClass": "m-medium" } }
}
}
```
### Building & Submitting
```bash
# Build for both platforms
eas build -p ios --profile production
eas build -p android --profile production
# Build + submit in one step
eas build -p ios --profile production --submit
# Or submit a finished build separately
eas submit -p ios
eas submit -p android
```
### Store Submission Notes
**iOS** — Run `eas credentials` to set up signing. Create the app record in App Store Connect, fill metadata, then `--submit` pushes the build to TestFlight automatically.
**Android** — Create a Google Play service account, download its JSON key, and reference it in `eas.json` under `submit.production.android.serviceAccountKeyPath`. The first build must be uploaded manually through Play Console; subsequent builds use `eas submit`.
### Over-the-Air Updates
For JS-only changes (no new native code), skip the full build/review cycle:
```bash
npx expo install expo-updates
eas update --branch production --message "Fix checkout rounding error"
```
### Web Hosting
```bash
npx expo export -p web
eas deploy # preview URL
eas deploy --prod # production
```
## CI/CD with EAS Workflows
Workflow files live in `.eas/workflows/` and follow a YAML schema:
```yaml
# .eas/workflows/release.yml
name: Release to stores
on:
push:
branches: [main]
jobs:
build:
type: build
params:
platform: all
profile: production
submit:
type: submit
needs: [build]
params:
platform: all
profile: production
```
```yaml
# .eas/workflows/pr-check.yml
name: PR check
on:
pull_request:
branches: [main]
jobs:
preview-build:
type: build
params:
platform: all
profile: preview
```
## DOM Components
The `"use dom"` directive lets you render web-only code inside a WebView on native while running it as standard DOM on web. Useful for libraries that depend on browser APIs (chart libraries, rich text editors, syntax highlighters).
```tsx
// components/RichPreview.tsx
"use dom";
import ReactMarkdown from "react-markdown";
export default function RichPreview({ markdown }: { markdown: string }) {
return <ReactMarkdown>{markdown}</ReactMarkdown>;
}
```
```tsx
// app/note/[id].tsx — native screen
import RichPreview from "@/components/RichPreview";
export default function NoteScreen() {
const { content } = useNote();
return (
<ScrollView>
<RichPreview markdown={content} />
</ScrollView>
);
}
```
Rules:
- `"use dom"` must be the first statement in the file
- One default export per file; cannot be mixed with native components
- Props must be serializable (strings, numbers, booleans, plain objects/arrays)
- Async function props bridge native actions into the webview (e.g., `onSave: (data) => Promise<void>`)
- Cannot be used in `_layout.tsx` files
- Router hooks that read native navigation state (`useLocalSearchParams`, `usePathname`, etc.) must be called in the native parent and passed as props

View File

@@ -0,0 +1,300 @@
# Forms Reference
React Hook Form + Zod validation for React Native / Expo.
## Setup
```bash
npx expo install react-hook-form zod @hookform/resolvers
```
## Basic Form
```tsx
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Min 8 characters"),
});
type FormData = z.infer<typeof schema>;
export function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
return (
<View>
{/* Controller pattern — repeat for each field */}
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput value={value} onChangeText={onChange} onBlur={onBlur}
placeholder="Email" keyboardType="email-address" autoCapitalize="none" />
)}
/>
{errors.email && <Text style={styles.error}>{errors.email.message}</Text>}
{/* Same Controller pattern for password, with secureTextEntry */}
<Pressable onPress={handleSubmit(onSubmit)} disabled={isSubmitting}>
<Text>{isSubmitting ? "Submitting..." : "Login"}</Text>
</Pressable>
</View>
);
}
```
## Zod Schema Patterns
```tsx
import { z } from "zod";
// Registration form
const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(50),
email: z.string().email("Invalid email address"),
password: z.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Must contain uppercase letter")
.regex(/[0-9]/, "Must contain a number"),
confirmPassword: z.string(),
age: z.number({ invalid_type_error: "Age must be a number" }).int().min(18, "Must be 18+").optional(),
role: z.enum(["admin", "user", "guest"]),
agreedToTerms: z.literal(true, { errorMap: () => ({ message: "Must agree to terms" }) }),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// All-optional schema — use .optional() or .partial()
const profileSchema = registerSchema.pick({ name: true, email: true }).partial();
// Nested objects — compose schemas with z.array() and references
const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), country: z.string().length(2) });
const orderSchema = z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).min(1), shippingAddress: addressSchema });
```
## Form State
```tsx
const {
control,
handleSubmit,
watch,
setValue,
getValues,
reset,
setError,
clearErrors,
formState: {
errors,
isSubmitting,
isValid,
isDirty, // Any field changed from defaultValues
dirtyFields, // Which fields changed
touchedFields, // Which fields were focused
},
} = useForm<FormData>({ resolver: zodResolver(schema) });
// Watch a field value
const password = watch("password");
const allValues = watch(); // Watch all
// Set a value programmatically
setValue("email", "prefilled@example.com", { shouldValidate: true });
// Reset form
reset(); // Back to defaultValues
reset({ email: "new@email.com" }); // Reset with new values
// Set server-side errors
setError("email", { message: "Email already in use" });
```
## Async Submit with Error Handling
```tsx
const { handleSubmit, setError } = useForm<FormData>();
const onSubmit = async (data: FormData) => {
try {
await api.post("/auth/register", data);
router.replace("/home");
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
setError("email", { message: "Email already registered" });
} else {
setError("root", { message: "Something went wrong. Please try again." });
}
}
};
// Display root error
{errors.root && <Text style={styles.rootError}>{errors.root.message}</Text>}
```
## Multi-Step Forms
```tsx
const schema = z.object({
step1: z.object({ name: z.string().min(1), email: z.string().email() }),
step2: z.object({ phone: z.string(), address: z.string() }),
step3: z.object({ password: z.string().min(8), confirmPassword: z.string() }),
});
type FormData = z.infer<typeof schema>;
export function MultiStepForm() {
const [step, setStep] = useState(1);
const { control, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const nextStep = async () => {
const stepKey = `step${step}` as keyof FormData;
const valid = await trigger(stepKey); // Validate only current step's fields
if (valid) setStep(s => s + 1);
};
// Render step component by index, with Back/Next/Submit navigation
// Key pattern: trigger(stepKey) validates only current step before advancing
return (/* StepOne | StepTwo | StepThree + Back/Next/Submit buttons */);
}
```
## Reusable Field Components
```tsx
// components/ui/FormField.tsx
import { Controller, Control, FieldValues, Path } from "react-hook-form";
interface FormFieldProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
label: string;
placeholder?: string;
secureTextEntry?: boolean;
keyboardType?: TextInputProps["keyboardType"];
}
export function FormField<T extends FieldValues>({
control, name, label, placeholder, secureTextEntry, keyboardType,
}: FormFieldProps<T>) {
// Wraps Controller with: label, styled TextInput, and error message display
// Uses fieldState.error for per-field error, accessibilityLabel for a11y
return (
<Controller control={control} name={name}
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
<View>
<Text>{label}</Text>
<TextInput value={value} onChangeText={onChange} onBlur={onBlur}
placeholder={placeholder} secureTextEntry={secureTextEntry} keyboardType={keyboardType}
style={[styles.input, error && styles.inputError]} accessibilityLabel={label} />
{error && <Text style={styles.errorText}>{error.message}</Text>}
</View>
)}
/>
);
}
// Usage
<FormField control={control} name="email" label="Email" keyboardType="email-address" />
<FormField control={control} name="password" label="Password" secureTextEntry />
```
## Dynamic Arrays
```tsx
import { useFieldArray } from "react-hook-form";
const schema = z.object({
tags: z.array(z.object({ value: z.string().min(1) })).min(1, "Add at least one tag"),
});
function TagsForm() {
const { control, handleSubmit } = useForm<z.infer<typeof schema>>();
const { fields, append, remove } = useFieldArray({ control, name: "tags" });
return (
<View>
{fields.map((field, index) => (
<View key={field.id} style={{ flexDirection: "row" }}>
<Controller
control={control}
name={`tags.${index}.value`}
render={({ field: { onChange, value } }) => (
<TextInput value={value} onChangeText={onChange} placeholder="Tag" />
)}
/>
<Pressable onPress={() => remove(index)}><Text></Text></Pressable>
</View>
))}
<Pressable onPress={() => append({ value: "" })}><Text>+ Add Tag</Text></Pressable>
</View>
);
}
```
## Keyboard Handling
```tsx
import { KeyboardAvoidingView, Platform, ScrollView } from "react-native";
export function FormScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled" // Tapping outside keyboard doesn't dismiss
>
<LoginForm />
</ScrollView>
</KeyboardAvoidingView>
);
}
```
## Testing Forms
```tsx
import { render, fireEvent, waitFor, screen } from "@testing-library/react-native";
import { userEvent } from "@testing-library/react-native";
it("validates required fields", async () => {
render(<LoginForm onSubmit={jest.fn()} />);
fireEvent.press(screen.getByText("Login")); // Submit without filling
await waitFor(() => {
expect(screen.getByText("Invalid email")).toBeTruthy();
expect(screen.getByText("Min 8 characters")).toBeTruthy();
});
});
it("submits with valid data", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
await user.type(screen.getByPlaceholderText("Password"), "password123");
await user.press(screen.getByText("Login"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com", password: "password123" });
});
});
```

View File

@@ -0,0 +1,163 @@
# Native Capabilities Reference
Camera, location, permissions, haptics, notifications, and biometrics for Expo/React Native.
## Permissions
All Expo modules that need permissions expose a `use*Permissions()` hook. Follow this pattern:
1. Call the permission hook to get current status and a request function
2. Check `status` — if not `granted`, show a rationale and call `requestPermission()`
3. If the user denies twice, `canAskAgain` becomes `false` — direct them to Settings
```tsx
import { useCameraPermissions } from "expo-camera";
const [permission, requestPermission] = useCameraPermissions();
if (!permission?.granted) {
// Show rationale, then call requestPermission()
}
```
| Module | Permission Hook |
|--------|----------------|
| `expo-camera` | `useCameraPermissions()` |
| `expo-location` | `useForegroundPermissions()` / `useBackgroundPermissions()` |
| `expo-media-library` | `usePermissions()` |
| `expo-notifications` | `getPermissionsAsync()` / `requestPermissionsAsync()` |
| `expo-contacts` | `usePermissions()` |
For modules without a hook, use `requestPermissionsAsync()` / `getPermissionsAsync()` directly.
## Camera
```tsx
import { CameraView, useCameraPermissions } from "expo-camera";
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
// Capture a photo
const photo = await cameraRef.current?.takePictureAsync();
// Toggle front/back
const [facing, setFacing] = useState<"front" | "back">("back");
```
For simple photo/video selection without a camera UI, use `expo-image-picker`:
```tsx
import * as ImagePicker from "expo-image-picker";
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
quality: 0.8,
});
```
## Location
```tsx
import * as Location from "expo-location";
// One-time location
const { status } = await Location.requestForegroundPermissionsAsync();
if (status === "granted") {
const location = await Location.getCurrentPositionAsync({});
// location.coords.latitude, location.coords.longitude
}
```
For background location tracking, request `requestBackgroundPermissionsAsync()` and register a background task. Background location requires the `location` background mode in `app.json`:
```json
{
"expo": {
"ios": { "infoPlist": { "UIBackgroundModes": ["location"] } }
}
}
```
## Haptics
```tsx
import * as Haptics from "expo-haptics";
// Light tap feedback (button press)
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Success / error / warning
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Selection change (picker scroll)
Haptics.selectionAsync();
```
| Style | When to Use |
|-------|-------------|
| `ImpactFeedbackStyle.Light` | Button taps, toggles |
| `ImpactFeedbackStyle.Medium` | Drag snaps, significant actions |
| `ImpactFeedbackStyle.Heavy` | Destructive actions, impacts |
| `NotificationFeedbackType.Success` | Task completed |
| `NotificationFeedbackType.Warning` | Attention needed |
| `NotificationFeedbackType.Error` | Action failed |
| `selectionAsync()` | Picker/slider value changes |
## Notifications
### Push Notifications (Expo)
```tsx
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
async function registerForPushNotifications() {
if (!Device.isDevice) return; // Push doesn't work on simulators
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") return;
const token = await Notifications.getExpoPushTokenAsync({
projectId: "your-project-id", // From app.json > extra > eas > projectId
});
// Send token.data to your server
}
```
### Notification Handlers
```tsx
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
// Listen for received/tapped notifications
const subscription = Notifications.addNotificationReceivedListener(notification => {
// Notification received while app is foregrounded
});
```
## Biometrics
```tsx
import * as LocalAuthentication from "expo-local-authentication";
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (hasHardware && isEnrolled) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to continue",
fallbackLabel: "Use passcode",
});
if (result.success) {
// Authenticated
}
}
```

View File

@@ -0,0 +1,271 @@
# Navigation Reference
Expo Router file-based navigation: Stack, Tabs, modals, links, and context menus.
## File Conventions
```
app/
_layout.tsx Root layout (providers, NativeTabs)
index.tsx → /
about.tsx → /about
user/
[id].tsx → /user/:id
[id]/
posts.tsx → /user/:id/posts
(tabs)/
_layout.tsx Tab navigator (group, not in URL)
home.tsx → /home
profile.tsx → /profile
(index,search)/
_layout.tsx Shared Stack for both tabs
index.tsx → /
search.tsx → /search
i/[id].tsx → /i/:id (shared detail screen)
api/
users+api.ts → /api/users (server route)
```
**Rules**:
- Routes live only in `app/` — never co-locate components, types, or utils there
- Always have a route matching `/` (may be inside a group)
- Remove old route files when restructuring navigation
- Use kebab-case filenames
## Root Layout (Stack)
```tsx
// app/_layout.tsx — root is always a Stack
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack
screenOptions={{
headerTransparent: true,
headerLargeTitle: true,
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="user/[id]" options={{ headerLargeTitle: false }} />
</Stack>
);
}
```
**Always set page title via `Stack.Screen options.title`**, never use a custom Text element as a title.
## Tabs — Which to Use
| Scenario | Use |
|----------|-----|
| Custom design system, cross-platform | **JS Tabs** (stable, fully customizable) |
| iOS-native look, Liquid Glass (iOS 26+) | **NativeTabs** (alpha, limited customization) |
## JS Tabs
```tsx
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
<Tabs.Screen
name="home"
options={{
tabBarLabel: "Home",
tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
}}
/>
</Tabs>
);
}
```
## NativeTabs (alpha, iOS 18+)
> Alpha API — all tabs render at once, limited customization, max 5 tabs on Android. Use when you want native iOS look (Liquid Glass, native blur/transitions) without rebuilding it yourself.
```tsx
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function Layout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Icon sf="house" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(profile)">
<NativeTabs.Trigger.Icon sf="person" />
<NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
```
## Shared Stack for Multiple Tabs
```tsx
// app/(index,search)/_layout.tsx — shared Stack for both index and search tabs
import { Stack } from "expo-router/stack";
const tabLabels: Record<string, string> = { index: "Home", search: "Explore" };
export default function Layout({ segment }: { segment: string }) {
const activeTab = segment.replace(/[()]/g, "");
return (
<Stack screenOptions={{ headerLargeTitle: true, headerBackButtonDisplayMode: "minimal" }}>
<Stack.Screen name={activeTab} options={{ title: tabLabels[activeTab] }} />
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
</Stack>
);
}
```
## Link Component
```tsx
import { Link } from "expo-router";
// Basic navigation
<Link href="/about">About</Link>
// Dynamic routes
<Link href={`/user/${userId}`}>Profile</Link>
// Wrapping custom component
<Link href="/settings" asChild>
<Pressable><Text>Settings</Text></Pressable>
</Link>
```
## Programmatic Navigation
```tsx
import { useRouter, useLocalSearchParams } from "expo-router";
const router = useRouter();
router.push("/settings");
router.replace("/login"); // No back button
router.back();
// Access route params
const { id } = useLocalSearchParams<{ id: string }>();
```
## Modals & Sheets
```tsx
// Modal presentation
<Stack.Screen options={{ presentation: "modal" }} />
// Form sheet with detents
<Stack.Screen
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" }, // Liquid glass on iOS 26+
}}
/>
```
## Context Menus on Links
```tsx
<Link href="/settings" asChild>
<Link.Trigger>
<Pressable><Card /></Pressable>
</Link.Trigger>
<Link.Menu>
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={handleShare}
/>
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={handleDelete}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
</Link.Menu>
</Link.Menu>
</Link>
```
## Link Previews (iOS only, requires Expo SDK 54+)
```tsx
<Link href="/detail">
<Link.Trigger>
<Pressable><Card /></Pressable>
</Link.Trigger>
<Link.Preview /> {/* Shows peek preview on 3D touch / long press */}
</Link>
```
## Header Search Bar
```tsx
// In Stack.Screen — preferred over building custom search UI
<Stack.Screen
options={{
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e) => setQuery(e.nativeEvent.text),
onCancelButtonPress: () => setQuery(""),
},
}}
/>
```
## Deep Linking
```json
// app.json
{
"expo": {
"scheme": "myapp",
"ios": {
"associatedDomains": ["applinks:myapp.example.com"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "myapp.example.com" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
```
Expo Router handles deep links automatically — `/user/123` maps to `app/user/[id].tsx`.
## ScrollView in Routes
When a route belongs to a Stack, its first child should almost always be a ScrollView:
```tsx
export default function HomeScreen() {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
{/* Content */}
</ScrollView>
);
}
```
Use `contentInsetAdjustmentBehavior="automatic"` on `ScrollView`, `FlatList`, and `SectionList` — this handles safe areas and header insets automatically. Prefer it over `<SafeAreaView>`.

View File

@@ -0,0 +1,346 @@
# Networking Reference
Building a robust data layer for Expo apps: API clients, server state, authentication, and server-side routes.
## API Client
### Setup
Create a thin wrapper around `fetch` (or `expo/fetch` on SDK 53+) rather than installing axios. Build a generic `request<T>(path, init?)` function that:
- Prepends `process.env.EXPO_PUBLIC_API_URL` to the path
- Defaults `Content-Type: application/json`, merges caller headers
- On `!res.ok`, throws an error with `status` and `body` attached (use `Object.assign`) so callers can branch on HTTP status
- Returns `res.json() as Promise<T>`
Then export convenience methods: `api.get<T>(path)`, `api.post<T>(path, body)`, etc., each delegating to `request()` with the appropriate method and `JSON.stringify(body)`.
### Typed Errors
Distinguish network-level failures (no connectivity, DNS) from HTTP-level errors (4xx/5xx). The wrapper above attaches `status` and `body` to thrown errors so callers can branch:
```tsx
try {
await api.post("/tasks", newTask);
} catch (err: any) {
if (err.status === 409) {
Alert.alert("Duplicate", "A task with that title already exists.");
} else if (err.status === undefined) {
Alert.alert("Offline", "Check your connection and try again.");
}
}
```
## Server State (React Query)
### Provider
```tsx
// app/_layout.tsx
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
const qc = new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
});
export default function RootLayout() {
return (
<QueryClientProvider client={qc}>
<Stack />
</QueryClientProvider>
);
}
```
### Reading Data
```tsx
function TaskList({ projectId }: { projectId: string }) {
const { data: tasks, isPending, error } = useQuery({
queryKey: ["projects", projectId, "tasks"],
queryFn: () => api.get<Task[]>(`/projects/${projectId}/tasks`),
});
if (isPending) return <ActivityIndicator />;
if (error) return <ErrorBanner message={error.message} />;
return (
<FlashList
data={tasks}
renderItem={({ item }) => <TaskRow task={item} />}
estimatedItemSize={56}
/>
);
}
```
### Writing Data
```tsx
function useCompleteTask(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (taskId: string) => api.put(`/tasks/${taskId}`, { done: true }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }),
});
}
```
### Optimistic Updates
For snappy UIs, update the cache before the server confirms:
```tsx
const toggle = useMutation({
mutationFn: (task: Task) => api.put(`/tasks/${task.id}`, { done: !task.done }),
onMutate: async (task) => {
await qc.cancelQueries({ queryKey });
const prev = qc.getQueryData<Task[]>(queryKey);
qc.setQueryData<Task[]>(queryKey, (old) =>
old?.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)),
);
return { prev };
},
onError: (_err, _task, ctx) => qc.setQueryData(queryKey, ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey }),
});
```
## Authentication
### Storing Credentials
Use `expo-secure-store` for any token or secret. AsyncStorage is unencrypted and readable on rooted/jailbroken devices.
```tsx
import * as SecureStore from "expo-secure-store";
const TOKEN_KEY = "session_token";
export async function saveToken(token: string) {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function getToken() {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export async function clearToken() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}
```
### Injecting Auth Headers
Extend the API client to attach the token automatically:
```tsx
export async function authRequest<T>(path: string, init?: RequestInit): Promise<T> {
const token = await getToken();
return request<T>(path, {
...init,
headers: { ...init?.headers, ...(token && { Authorization: `Bearer ${token}` }) },
});
}
```
### Refreshing Expired Tokens
Avoid stampeding refresh calls when multiple requests discover the token is expired simultaneously. Hold a single in-flight refresh promise and let all waiters share it:
```tsx
let pending: Promise<string> | null = null;
async function getFreshToken(): Promise<string> {
if (pending) return pending;
pending = (async () => {
const refresh = await SecureStore.getItemAsync("refresh_token");
const { accessToken } = await api.post<{ accessToken: string }>("/auth/refresh", { refresh });
await saveToken(accessToken);
return accessToken;
})();
try {
return await pending;
} finally {
pending = null;
}
}
```
## Environment Variables
```bash
# .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000
# .env.production
EXPO_PUBLIC_API_URL=https://api.production.example.com
```
- The `EXPO_PUBLIC_` prefix makes a variable available in client JS (inlined at build time)
- Variables **without** the prefix are only accessible in server-side API routes
- Never expose database credentials or write-capable API keys via `EXPO_PUBLIC_`
- Restart the dev server after editing `.env` files
Type the variables for autocomplete:
```tsx
// env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
}
}
}
export {};
```
## Offline & Connectivity
Track device connectivity with `@react-native-community/netinfo` and wire it into React Query so queries automatically pause offline and resume on reconnect:
```tsx
// app/_layout.tsx (once, at startup)
import { onlineManager } from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
);
```
To show an in-app banner, subscribe separately:
```tsx
function useOnline() {
const [online, setOnline] = useState(true);
useEffect(() => NetInfo.addEventListener((s) => setOnline(!!s.isConnected)), []);
return online;
}
```
## Request Lifecycle
### Cancellation
When a component unmounts mid-request, abort the in-flight fetch to avoid setting state on an unmounted component:
```tsx
useEffect(() => {
const ac = new AbortController();
api.get(`/projects/${id}`, { signal: ac.signal }).then(setProject);
return () => ac.abort();
}, [id]);
```
React Query handles cancellation automatically for queries — no extra work needed.
### Retries
React Query retries failed queries by default (3 attempts with exponential backoff). For mutations or non-React-Query code, implement manually:
```tsx
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
throw new Error("unreachable");
}
```
## Server-Side API Routes
Expo Router supports `+api.ts` files that run on the server (deployed to EAS Hosting / Cloudflare Workers). Use them when you need to keep secrets server-side, proxy third-party APIs, or run database queries.
### Conventions
```
app/
api/
health+api.ts → GET /api/health
projects+api.ts → GET|POST /api/projects
projects/[id]+api.ts → GET|PUT|DELETE /api/projects/:id
webhooks/payments+api.ts → POST /api/webhooks/payments
```
Export a named function per HTTP method:
```ts
// app/api/projects+api.ts
export async function GET(req: Request) {
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor");
const rows = await db.query("SELECT * FROM projects WHERE id > ? LIMIT 20", [cursor ?? 0]);
return Response.json(rows);
}
export async function POST(req: Request) {
const { name, description } = await req.json();
const [row] = await db.insert(projectsTable).values({ name, description }).returning();
return Response.json(row, { status: 201 });
}
```
### Secrets
Variables **without** the `EXPO_PUBLIC_` prefix are server-only:
```ts
// app/api/ai/summarize+api.ts
const LLM_KEY = process.env.LLM_API_KEY; // never reaches the client bundle
export async function POST(req: Request) {
const { text } = await req.json();
const res = await fetch("https://api.llm.example.com/v1/chat", {
method: "POST",
headers: { Authorization: `Bearer ${LLM_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: `Summarize: ${text}` }] }),
});
return Response.json(await res.json());
}
```
### Webhooks
```ts
// app/api/webhooks/payments+api.ts — verify signature, then handle event
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WH_SECRET!);
if (event.type === "checkout.session.completed") {
await activateSubscription(event.data.object.customer as string);
}
```
### Protecting Routes
```ts
// lib/require-auth.ts — extract and verify JWT from Authorization header, throw Response on failure
export async function requireAuth(req: Request): Promise<string> {
const header = req.headers.get("Authorization");
if (!header?.startsWith("Bearer "))
throw Response.json({ error: "unauthorized" }, { status: 401 });
const uid = await verifyJwt(header.slice(7));
if (!uid) throw Response.json({ error: "invalid token" }, { status: 401 });
return uid;
}
// Usage in route: const uid = await requireAuth(req);
```
### Deploying
```bash
npx expo export
eas deploy # preview
eas deploy --prod # production
# Set server-only secrets
eas env:create --name LLM_API_KEY --value "sk-..." --environment production
```
API routes run on Cloudflare Workers — no `fs` module, 30 s CPU limit, use Web APIs (`fetch`, `crypto.subtle`) instead of Node built-ins.

View File

@@ -0,0 +1,215 @@
# Performance Reference
Diagnosing and fixing performance issues in React Native / Expo apps.
## Profiling Workflow
Before optimizing, identify the actual bottleneck:
1. **JS thread** — Open React Native DevTools (press `j` in Metro terminal) → Profiler tab → record interaction → look for components with long render times
2. **Native thread** — iOS: Xcode Instruments (Time Profiler); Android: Android Studio CPU Profiler
3. **Measure, don't guess** — Always reproduce the issue in a release-like build (`npx expo run:ios --configuration Release`)
## Rendering
### Virtualized Lists
Never render large datasets inside a `ScrollView`. Use a virtualized list that recycles off-screen views:
```tsx
import { FlashList } from "@shopify/flash-list";
function ProductCatalog({ products }: { products: Product[] }) {
return (
<FlashList
data={products}
renderItem={({ item }) => <ProductRow product={item} />}
estimatedItemSize={72}
keyExtractor={(p) => p.sku}
/>
);
}
const ProductRow = memo(function ProductRow({ product }: { product: Product }) {
return (
<View style={rowStyles.container}>
<Image source={product.thumbnail} style={rowStyles.image} />
<Text style={rowStyles.title}>{product.name}</Text>
</View>
);
});
```
Key points:
- Wrap list items with `memo` to skip re-renders when props haven't changed
- Always provide `estimatedItemSize` — FlashList uses it for layout estimation
- Extract `renderItem` or use a named component to keep stable references
### Minimizing Re-renders
**Split state by concern.** A single large state object forces every subscriber to re-render on any change:
```tsx
// Zustand — select only the slice you need
const count = useStore((s) => s.cart.itemCount);
// Jotai — one atom per concern
const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
});
```
**React Compiler** (Expo SDK 54+) automatically memoizes components and hooks. Enable it to eliminate most manual `useMemo`/`useCallback`:
```json
// app.json
{ "expo": { "experiments": { "reactCompiler": true } } }
```
### Deferred Updates
When a state change triggers expensive computation (filtering a long list, rendering a complex tree), defer the update so typing or scrolling stays responsive:
```tsx
const [search, setSearch] = useState("");
const deferred = useDeferredValue(search);
const results = useMemo(
() => catalog.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())),
[catalog, deferred],
);
```
### TextInput on Android
Controlled `TextInput` (with `value` + `onChangeText`) can lag on Android because every keystroke round-trips through the JS thread. For search bars or other high-frequency inputs, prefer uncontrolled mode:
```tsx
const ref = useRef<TextInput>(null);
<TextInput
ref={ref}
defaultValue=""
onEndEditing={(e) => handleSearch(e.nativeEvent.text)}
/>
```
## Startup Time (TTI)
### Measuring
```tsx
import { PerformanceObserver, performance } from "react-native-performance";
performance.mark("nativeLaunch");
export default function App() {
useEffect(() => {
performance.measure("TTI", "nativeLaunch");
}, []);
// ...
}
```
Always measure **cold starts** — kill the app completely before each measurement.
### Reducing TTI
- **Android bundle mmap** — Set `expo.useLegacyPackaging=false` in `android/gradle.properties` so Hermes memory-maps the bundle instead of decompressing it
- **Preload heavy routes** — Call `preloadRouteAsync("/dashboard")` (from `expo-router`) while the user is still on the splash/login screen
- **Lazy-load non-critical screens** — Screens behind deep navigation don't need to be in the initial bundle
## Bundle & App Size
### Inspecting the Bundle
```bash
npx expo export --platform ios --source-maps --output-dir dist
npx source-map-explorer dist/bundles/ios/*.js
```
Common wins:
- **Direct imports** — `import groupBy from "lodash/groupBy"` instead of `import { groupBy } from "lodash"`
- **Remove dead Intl polyfills** — Hermes ships with built-in `Intl` support since SDK 50
- **Tree shaking** — Enable via `"experiments": { "treeShaking": true }` in app config (SDK 52+)
### Shrinking the Native Binary
```properties
# android/gradle.properties
android.enableProguardInReleaseBuilds=true
```
Inspect the final artifact:
- iOS: download the `.ipa` from EAS, unzip, check `Payload/*.app` size
- Android: open the `.aab`/`.apk` in Android Studio → Build → Analyze APK
## Memory
### Preventing Leaks
Every subscription, listener, or long-lived resource acquired in `useEffect` must be cleaned up:
```tsx
useEffect(() => {
const sub = AppState.addEventListener("change", onAppStateChange);
return () => sub.remove();
}, []);
```
For fetch calls, pass an `AbortSignal` and abort on unmount:
```tsx
useEffect(() => {
const ac = new AbortController();
loadProducts(ac.signal);
return () => ac.abort();
}, [categoryId]);
```
### Native Memory
- Monitor with Xcode Memory Graph Debugger (iOS) or Android Studio Memory Profiler
- Release heavy native resources (camera sessions, audio players) in cleanup
- In Swift/Kotlin modules, watch for retain cycles — use `[weak self]` / `WeakReference`
## Animations
Only animate **`transform`** and **`opacity`**. These properties are composited on the GPU and don't trigger layout recalculation:
```tsx
const style = useAnimatedStyle(() => ({
opacity: withTiming(visible.value ? 1 : 0),
transform: [{ translateY: withSpring(offset.value) }],
}));
```
Animating `width`, `height`, `margin`, `padding`, or `top`/`left` forces the layout engine to re-measure on every frame — a common source of dropped frames.
Keep gesture callbacks on the UI thread:
```tsx
const drag = Gesture.Pan().onUpdate((e) => {
"worklet";
translateX.value = e.translationX;
});
```
## Native Module Performance
- Prefer **async** Turbo Module methods — synchronous calls block the JS thread
- Use native SDK implementations over JS polyfills (`expo-crypto` over `crypto-js`, `react-native-mmkv` over AsyncStorage for hot paths)
- **Android 16KB page alignment** is required for Google Play (2025+). Verify third-party `.so` files are compiled with 16KB alignment
## Troubleshooting Guide
| Symptom | Where to Look | Likely Fix |
|---------|--------------|------------|
| Scroll jank in long lists | JS thread — re-renders | Virtualized list + memoized items |
| Typing lag in search bar | JS thread — controlled input | Uncontrolled `TextInput` with `defaultValue` |
| Slow cold start | Bundle size, sync init | Mmap bundle, preload routes, lazy screens |
| App binary too large | Native assets, unused libs | R8 (Android), analyze bundle, direct imports |
| Growing memory over time | Effect cleanup | Return teardown from every `useEffect` |
| Choppy enter/exit animation | Animated properties | Only `transform` + `opacity`, use worklets |
| Re-renders cascade across app | Global state shape | Atomic selectors (Zustand/Jotai), React Compiler |

View File

@@ -0,0 +1,230 @@
# State Management Reference
Patterns for local, shared, and server state in React Native / Expo apps.
## Decision Guide
| State Type | Solution |
|------------|----------|
| Local UI state (toggle, input) | `useState` / `useReducer` |
| Shared app-wide state | Zustand or Jotai |
| Server/async data | React Query (TanStack Query) |
| Form state | React Hook Form (see forms.md) |
| Auth / session | Zustand + `expo-secure-store` |
**Avoid**: Redux for new projects (boilerplate), Context for high-frequency updates (re-render overhead).
## useState / useReducer
```tsx
// Simple toggle
const [isOpen, setIsOpen] = useState(false);
// Complex local state — useReducer
type State = { count: number; status: "idle" | "loading" | "error" };
type Action = { type: "increment" } | { type: "setStatus"; payload: State["status"] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment": return { ...state, count: state.count + 1 };
case "setStatus": return { ...state, status: action.payload };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0, status: "idle" });
dispatch({ type: "increment" });
```
## Zustand (Shared State)
```bash
npx expo install zustand
```
```tsx
// stores/settings-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
interface SettingsStore {
theme: "light" | "dark";
locale: string;
setTheme: (theme: "light" | "dark") => void;
setLocale: (locale: string) => void;
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: "light",
locale: "en",
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: "settings-storage",
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// Usage
const { theme, setTheme } = useSettingsStore();
const locale = useSettingsStore((s) => s.locale); // Selector — minimizes re-renders
```
```tsx
// stores/cart-store.ts
interface CartStore {
items: CartItem[];
add: (product: Product) => void;
remove: (id: string) => void;
clear: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()((set, get) => ({
items: [],
add: (product) => set((s) => ({
items: [...s.items, { product, quantity: 1 }],
})),
remove: (id) => set((s) => ({
items: s.items.filter((i) => i.product.id !== id),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
}));
```
## Jotai (Atomic State)
```bash
npx expo install jotai
```
```tsx
// atoms/user-atoms.ts
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import AsyncStorage from "@react-native-async-storage/async-storage";
const storage = createJSONStorage(() => AsyncStorage);
export const userAtom = atom<User | null>(null);
export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light", storage);
// Derived atom — computed from others
export const isAdminAtom = atom((get) => get(userAtom)?.role === "admin");
```
```tsx
// Usage — component only re-renders when its atoms change
import { useAtom, useAtomValue, useSetAtom } from "jotai";
function Header() {
const user = useAtomValue(userAtom); // read-only
const setTheme = useSetAtom(themeAtom); // write-only
const [theme, setThemeRW] = useAtom(themeAtom); // read + write
return <Text>{user?.name}</Text>;
}
```
**Zustand vs Jotai**:
- **Zustand** — store-based, better for related state with actions (auth, cart)
- **Jotai** — atom-based, better for independent values, fine-grained subscriptions, avoids re-renders
## React Query (Server State)
See [networking.md](networking.md) for full reference. Key patterns:
```tsx
// Queries — read
const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
// Mutations — write
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
});
// Optimistic update
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ["user", newUser.id] });
const prev = queryClient.getQueryData(["user", newUser.id]);
queryClient.setQueryData(["user", newUser.id], newUser);
return { prev };
},
onError: (_err, variables, context) => {
queryClient.setQueryData(["user", variables.id], context?.prev);
},
});
```
## Minimize Re-renders
### Zustand Selectors
```tsx
// ✗ Wrong — re-renders on any store change
const store = useAuthStore();
// ✓ Correct — re-renders only when user changes
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); // Actions are stable references
```
### Dispatcher Pattern
```tsx
// ✗ Wrong — passes callbacks that recreate on every render
function Parent() {
const [count, setCount] = useState(0);
return <Child onIncrement={() => setCount(c => c + 1)} />;
}
// ✓ Correct — dispatcher reference is stable
function Parent() {
const [count, dispatch] = useReducer(reducer, 0);
return <Child dispatch={dispatch} />;
}
```
### React Compiler (SDK 54+)
With React Compiler enabled, `memo`, `useCallback`, and `useMemo` are often unnecessary:
```json
// app.json
{ "expo": { "experiments": { "reactCompiler": true } } }
```
## Context (Use Sparingly)
Context is suitable for infrequently-changing values (theme, locale, auth status). **Avoid** for high-frequency updates like scroll position or form input.
```tsx
const ThemeContext = createContext<"light" | "dark">("light");
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
return <ThemeContext value={theme}>{children}</ThemeContext>; // React 19+
}
// Consume
const theme = use(ThemeContext); // React 19+
```
## Fallback on First Render
```tsx
// ✓ Always show fallback while async state loads
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });
if (isLoading) return <UserProfileSkeleton />;
if (!data) return null;
return <Profile user={data} />;
}
```

View File

@@ -0,0 +1,117 @@
# Styling Reference
StyleSheet, NativeWind/Tailwind, platform-specific styles, and theming for Expo/React Native.
## StyleSheet
```tsx
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff" },
text: { fontSize: 16, fontWeight: "600" },
});
```
Prefer `StyleSheet.create` over inline style objects — it validates styles at creation time and enables potential future optimizations.
## Platform-Specific Styles
```tsx
import { Platform, StyleSheet } from "react-native";
const styles = StyleSheet.create({
shadow: Platform.select({
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
android: { elevation: 4 },
}),
});
```
Since React Native 0.76+, `boxShadow` is supported as a unified cross-platform shadow API. Prefer it over platform-specific shadow properties when targeting New Architecture.
## NativeWind / Tailwind CSS
For existing projects, check which NativeWind version is in `package.json` and follow the corresponding docs. For new projects, use NativeWind v4 (stable).
### Installation (NativeWind v4)
```bash
npx expo install nativewind tailwindcss@3 \
tailwind-merge clsx
```
### Configuration
```js
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["nativewind/babel"],
};
};
```
```js
// tailwind.config.js
module.exports = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: { extend: {} },
};
```
```css
/* global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
```
```tsx
// app/_layout.tsx
import "../global.css";
```
### Usage
```tsx
<View className="flex-1 bg-white p-4">
<Text className="text-lg font-semibold text-gray-900">Title</Text>
<Pressable className="mt-4 rounded-lg bg-blue-500 px-4 py-2">
<Text className="text-center text-white font-medium">Button</Text>
</Pressable>
</View>
```
### Conditional Classes
```tsx
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
<View className={cn("p-4", isActive && "bg-blue-500", isDisabled && "opacity-50")} />
```
## Theming and Dark Mode
For apps using NativeWind, use Tailwind's `dark:` variant:
```tsx
<View className="bg-white dark:bg-gray-900">
<Text className="text-gray-900 dark:text-white">Adaptive text</Text>
</View>
```
For StyleSheet-based projects, read the system color scheme and map it to a theme object:
```tsx
import { useColorScheme } from "react-native";
const colorScheme = useColorScheme(); // "light" | "dark"
```
Keep color tokens in a central `constants/colors.ts` file with light and dark variants. Pass the active palette via React Context or a Zustand store.

View File

@@ -0,0 +1,342 @@
# Testing Reference
Jest, React Native Testing Library, and E2E testing for Expo/React Native apps.
## Setup
```bash
npx expo install jest-expo @testing-library/react-native
```
```json
// package.json
{
"jest": {
"preset": "jest-expo",
"setupFilesAfterSetup": ["@testing-library/react-native/extend-expect"]
}
}
```
```bash
npx jest # Run all tests
npx jest --watch # Watch mode
npx jest --coverage # Coverage report
npx jest path/to/test.tsx # Single file
```
## React Native Testing Library
### Basic Component Test
```tsx
// components/__tests__/Button.test.tsx
import { render, fireEvent, screen } from "@testing-library/react-native";
import { Button } from "../Button";
describe("Button", () => {
it("renders label", () => {
render(<Button label="Submit" onPress={() => {}} />);
expect(screen.getByText("Submit")).toBeTruthy();
});
it("calls onPress when tapped", () => {
const onPress = jest.fn();
render(<Button label="Submit" onPress={onPress} />);
fireEvent.press(screen.getByText("Submit"));
expect(onPress).toHaveBeenCalledTimes(1);
});
it("is disabled when loading", () => {
render(<Button label="Submit" onPress={() => {}} loading />);
expect(screen.getByRole("button")).toBeDisabled();
});
});
```
### Queries
```tsx
// Prefer accessible queries
screen.getByRole("button", { name: "Submit" });
screen.getByLabelText("Email");
screen.getByPlaceholderText("Enter email");
// Text content
screen.getByText("Welcome back");
screen.getByText(/welcome/i); // Regex — case insensitive
// Test IDs (last resort)
screen.getByTestId("user-avatar");
// Async queries
await screen.findByText("Loaded content"); // Waits for element to appear
await screen.findAllByRole("listitem");
// Non-existence
expect(screen.queryByText("Error")).toBeNull();
```
### User Events
```tsx
import { userEvent } from "@testing-library/react-native";
it("submits form on valid input", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
await user.type(screen.getByPlaceholderText("Password"), "password123");
await user.press(screen.getByRole("button", { name: "Login" }));
expect(mockSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123",
});
});
```
### Testing Async State
```tsx
import { waitFor, act } from "@testing-library/react-native";
it("shows user data after loading", async () => {
render(<UserProfile userId="123" />);
// Loading state
expect(screen.getByTestId("loading-indicator")).toBeTruthy();
// Wait for data
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeTruthy();
});
expect(screen.queryByTestId("loading-indicator")).toBeNull();
});
```
### Testing with React Query
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
}
function renderWithQuery(ui: ReactElement) {
const client = createTestQueryClient();
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
it("fetches and displays posts", async () => {
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: "1", title: "Post 1" }]),
});
renderWithQuery(<PostsList />);
await waitFor(() => {
expect(screen.getByText("Post 1")).toBeTruthy();
});
});
```
### Testing with Zustand
```tsx
import { useAuthStore } from "../../stores/auth-store";
beforeEach(() => {
// Reset store state before each test
useAuthStore.setState({ user: null, token: null });
});
it("shows user name when logged in", () => {
useAuthStore.setState({ user: { id: "1", name: "Alice" }, token: "tok" });
render(<Header />);
expect(screen.getByText("Alice")).toBeTruthy();
});
```
### Testing Navigation (Expo Router)
```tsx
import { renderRouter, screen } from "expo-router/testing-library";
it("navigates to detail screen", async () => {
renderRouter({
index: () => <HomeScreen />,
"user/[id]": () => <UserScreen />,
});
fireEvent.press(screen.getByText("View Profile"));
await waitFor(() => {
expect(screen.getByTestId("user-screen")).toBeTruthy();
});
});
```
## Mocking
### Mock Expo Modules
```tsx
// __mocks__/expo-secure-store.ts
export const getItemAsync = jest.fn().mockResolvedValue(null);
export const setItemAsync = jest.fn().mockResolvedValue(undefined);
export const deleteItemAsync = jest.fn().mockResolvedValue(undefined);
```
```tsx
// In test
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn().mockResolvedValue("mock-token"),
setItemAsync: jest.fn(),
}));
```
### Mock fetch / API calls
```tsx
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("handles API error", async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: "Server error" }),
});
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText("Server error")).toBeTruthy();
});
});
```
### Mock react-native modules
```tsx
// jest.setup.ts
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
jest.mock("@react-native-community/netinfo", () => ({
addEventListener: jest.fn(() => jest.fn()),
fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
}));
```
## Unit Tests (Non-UI Logic)
```tsx
// utils/__tests__/format.test.ts
import { formatCurrency, formatDate } from "../format";
describe("formatCurrency", () => {
it("formats USD", () => expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50"));
it("handles zero", () => expect(formatCurrency(0, "USD")).toBe("$0.00"));
it("handles negative", () => expect(formatCurrency(-50, "USD")).toBe("-$50.00"));
});
```
```tsx
// stores/__tests__/cart-store.test.ts
import { useCartStore } from "../cart-store";
beforeEach(() => useCartStore.setState({ items: [] }));
describe("CartStore", () => {
it("adds item", () => {
useCartStore.getState().add(mockProduct);
expect(useCartStore.getState().items).toHaveLength(1);
});
it("calculates total", () => {
useCartStore.getState().add({ ...mockProduct, price: 10 });
expect(useCartStore.getState().total()).toBe(10);
});
});
```
## E2E Testing (Maestro)
Maestro is the recommended E2E tool for Expo — no build configuration needed.
```bash
# Install
curl -Ls "https://get.maestro.mobile.dev" | bash
# Run flow
maestro test flows/login.yaml
```
```yaml
# flows/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn:
text: "Sign In"
- inputText:
id: "email-input"
text: "user@example.com"
- inputText:
id: "password-input"
text: "password123"
- tapOn:
text: "Login"
- assertVisible:
text: "Welcome back"
- takeScreenshot: login-success
```
```yaml
# flows/create-post.yaml
appId: com.example.myapp
---
- launchApp
- runFlow: ./login.yaml
- tapOn:
id: "new-post-button"
- inputText:
id: "post-title"
text: "My Test Post"
- tapOn:
text: "Publish"
- assertVisible:
text: "My Test Post"
```
## Testing Checklist
| Layer | What to Test |
|-------|-------------|
| Unit | Business logic, stores, utility functions, hooks |
| Component | Renders correctly, user interactions, loading/error states |
| Integration | Component + store/query working together |
| E2E | Critical user flows (login, checkout, core feature) |
## Common Mistakes
| Wrong | Right |
|-------|-------|
| `getByTestId` everywhere | Use accessible queries (`getByRole`, `getByLabelText`) |
| Testing implementation details | Test behavior the user sees |
| No `waitFor` on async operations | `waitFor` or `findBy*` for async |
| Real network calls in tests | Mock `fetch` or use MSW |
| Testing every line | Focus on behavior, not coverage % |