feat: add react-native skill
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
149
skills/react-native-dev/SKILL.md
Normal file
149
skills/react-native-dev/SKILL.md
Normal 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.*
|
||||||
254
skills/react-native-dev/references/animations.md
Normal file
254
skills/react-native-dev/references/animations.md
Normal 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); // 0–1
|
||||||
|
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 |
|
||||||
124
skills/react-native-dev/references/components.md
Normal file
124
skills/react-native-dev/references/components.md
Normal 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";
|
||||||
|
```
|
||||||
527
skills/react-native-dev/references/engineering.md
Normal file
527
skills/react-native-dev/references/engineering.md
Normal 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
|
||||||
300
skills/react-native-dev/references/forms.md
Normal file
300
skills/react-native-dev/references/forms.md
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
163
skills/react-native-dev/references/native-capabilities.md
Normal file
163
skills/react-native-dev/references/native-capabilities.md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
271
skills/react-native-dev/references/navigation.md
Normal file
271
skills/react-native-dev/references/navigation.md
Normal 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>`.
|
||||||
346
skills/react-native-dev/references/networking.md
Normal file
346
skills/react-native-dev/references/networking.md
Normal 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.
|
||||||
215
skills/react-native-dev/references/performance.md
Normal file
215
skills/react-native-dev/references/performance.md
Normal 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 |
|
||||||
230
skills/react-native-dev/references/state-management.md
Normal file
230
skills/react-native-dev/references/state-management.md
Normal 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} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
117
skills/react-native-dev/references/styling.md
Normal file
117
skills/react-native-dev/references/styling.md
Normal 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.
|
||||||
342
skills/react-native-dev/references/testing.md
Normal file
342
skills/react-native-dev/references/testing.md
Normal 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 % |
|
||||||
Reference in New Issue
Block a user