react-native-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact Native / Expo Patterns
React Native / Expo 开发模式
Practical patterns for building production React Native apps with Expo. Covers navigation, state, data fetching, lists, styling, and native APIs. Pairs with the ruleset: rules say what to enforce, this skill shows how.
rules/react-native/Libraries named below (NativeWind, Zustand/Jotai, TanStack Query) are common, well-established options shown for illustration — the patterns matter more than the specific package, and any equivalent works. Zod is used for validation to stay consistent with ECC's existing rules.
typescript/These patterns assume the managed Expo workflow (Expo Router, EAS, modules) on the New Architecture (the default in recent Expo SDKs, mandatory from SDK 55+). They do NOT assume the browser DOM — React Native has no , no URL bar, and no web data-fetching defaults.
expo-*<div>这是一套使用Expo构建生产级React Native应用的实用模式,涵盖导航、状态管理、数据获取、列表、样式处理及原生API。与规则集配套使用:规则集规定需要遵循的内容,本技能则展示具体实现方式。
rules/react-native/下文提及的库(NativeWind、Zustand/Jotai、TanStack Query)是用于示例说明的主流成熟选择——模式本身比具体包更重要,任何同类替代方案均可适用。为与ECC现有规则保持一致,使用Zod进行校验。
typescript/这些模式基于Expo托管工作流(Expo Router、EAS、模块)及新架构(最新Expo SDK默认采用,SDK 55+强制要求)。它们不依赖浏览器DOM——React Native没有、地址栏,也没有Web端默认的数据获取机制。
expo-*<div>When to Activate
适用场景
Use this skill when:
- Building or editing React Native / Expo screens, components, or navigation
- Setting up routing with Expo Router (file-based directory)
app/ - Deciding where state belongs (server cache vs client store vs route params vs form)
- Wiring data fetching with TanStack Query and validating responses with Zod
- Rendering long or heavy lists
- Choosing or applying a styling approach (NativeWind or StyleSheet)
- Accessing native device APIs (camera, location, notifications) or secure storage
- Reviewing RN code for mobile-specific issues
Do NOT use the web/React-DOM patterns here — URL-as-state, , and SWR-for-browser do not apply to React Native.
<div>在以下场景中使用本技能:
- 构建或编辑React Native/Expo页面、组件或导航
- 使用Expo Router(基于文件的目录)配置路由
app/ - 确定状态归属(服务端缓存 vs 客户端存储 vs 路由参数 vs 表单)
- 结合TanStack Query实现数据获取并使用Zod校验响应
- 渲染长列表或重型列表
- 选择或应用样式方案(NativeWind或StyleSheet)
- 访问原生设备API(相机、定位、通知)或安全存储
- 审查RN代码中的移动端特定问题
请勿在此使用Web/React-DOM模式——基于URL的状态、、浏览器端SWR等均不适用于React Native。
<div>Core Concepts
核心概念
Project structure (Expo Router)
项目结构(Expo Router)
File-based routing under . Keep route files thin: they read and validate params, then delegate to a screen component that lives in or .
app/components/features/app/
_layout.tsx # root stack
(tabs)/
_layout.tsx # tab navigator
index.tsx # Home
user/[id].tsx # dynamic route
components/
features/
user/UserProfile.tsx基于目录的文件路由。保持路由文件简洁:仅读取并校验参数,然后委托给位于或中的页面组件。
app/components/features/app/
_layout.tsx # 根导航栈
(tabs)/
_layout.tsx # 标签页导航器
index.tsx # 首页
user/[id].tsx # 动态路由
components/
features/
user/UserProfile.tsxNavigation: validate route params
导航:校验路由参数
Deep links and dynamic routes deliver untrusted strings. Validate them with Zod before use.
tsx
// app/user/[id].tsx
import { useLocalSearchParams, router } from 'expo-router'
import { z } from 'zod'
import { UserProfile } from '@/features/user/UserProfile'
const Params = z.object({ id: z.string().uuid() })
export default function UserRoute() {
const parsed = Params.safeParse(useLocalSearchParams())
if (!parsed.success) {
router.replace('/not-found')
return null
}
return <UserProfile userId={parsed.data.id} />
}深度链接和动态路由传递的是不可信字符串,使用Zod校验后再使用。
tsx
// app/user/[id].tsx
import { useLocalSearchParams, router } from 'expo-router'
import { z } from 'zod'
import { UserProfile } from '@/features/user/UserProfile'
const Params = z.object({ id: z.string().uuid() })
export default function UserRoute() {
const parsed = Params.safeParse(useLocalSearchParams())
if (!parsed.success) {
router.replace('/not-found')
return null
}
return <UserProfile userId={parsed.data.id} />
}State: keep concerns separate
状态:分离关注点
Do not duplicate server data into a client store. Each concern has its own home.
| Concern | Common choices |
|---|---|
| Server state (remote data) | a server-cache library (TanStack Query, SWR) |
| Client/UI state | a lightweight store (Zustand, Jotai) or Context |
| Route/navigation state | Expo Router params |
| Form state | a form library (e.g. React Hook Form) + schema validation |
| Secrets / tokens | |
| Non-secret persistence | |
Prefer local until state genuinely needs sharing.
useState请勿将服务端数据复制到客户端存储中,每种状态都有其专属存储位置。
| 关注点 | 常见选择 |
|---|---|
| 服务端状态(远程数据) | 服务端缓存库(TanStack Query、SWR) |
| 客户端/UI状态 | 轻量级存储(Zustand、Jotai)或Context |
| 路由/导航状态 | Expo Router参数 |
| 表单状态 | 表单库(如React Hook Form)+ schema校验 |
| 密钥/令牌 | |
| 非机密持久化存储 | |
优先使用本地,直到状态确实需要共享时再更换方案。
useStateData fetching: a cache library + Zod
数据获取:缓存库 + Zod
Use a server-cache library (TanStack Query, SWR) instead of fetch-in-. Validate at the boundary and infer types from the schema. Handle loading, error, and empty states explicitly. (Example uses TanStack Query.)
useEffecttsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
const User = z.object({ id: z.string(), email: z.string().email() })
type User = z.infer<typeof User>
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => User.parse(await api.getUser(id)),
})
}
export function useUpdateEmail(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (email: string) => api.updateEmail(id, email),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
})
}使用服务端缓存库(TanStack Query、SWR)替代中直接调用fetch。在边界处校验数据并从schema中推断类型。显式处理加载、错误和空状态。(示例使用TanStack Query。)
useEffecttsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
const User = z.object({ id: z.string(), email: z.string().email() })
type User = z.infer<typeof User>
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => User.parse(await api.getUser(id)),
})
}
export function useUpdateEmail(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (email: string) => api.updateEmail(id, email),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
})
}Lists: virtualize, never map a big array in a ScrollView
列表:使用虚拟化,切勿在ScrollView中直接映射大数组
tsx
import { FlatList } from 'react-native'
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem} // memoized
initialNumToRender={10}
windowSize={5}
/>Use (Shopify) for large or heterogeneous lists.
FlashListtsx
import { FlatList } from 'react-native'
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem} // 已记忆化
initialNumToRender={10}
windowSize={5}
/>对于大型或异构列表,使用(Shopify出品)。
FlashListStyling: pick one system
样式:选择一套系统
StyleSheet.create()tsx
// NativeWind
<View className="p-4 rounded-2xl bg-white">
<Text className="text-base font-semibold">Hello</Text>
</View>
// StyleSheet
const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } })
<View style={styles.card}>...</View>StyleSheet.create()tsx
// NativeWind
<View className="p-4 rounded-2xl bg-white">
<Text className="text-base font-semibold">Hello</Text>
</View>
// StyleSheet
const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } })
<View style={styles.card}>...</View>Native APIs: wrap in hooks, clean up effects
原生API:封装在hooks中,清理副作用
Keep Expo SDK calls and subscriptions inside hooks, not in JSX. Always clean up.
use*tsx
import { useEffect, useState } from 'react'
import * as Location from 'expo-location'
type LocationState =
| { status: 'loading' }
| { status: 'denied' }
| { status: 'granted'; coords: Location.LocationObjectCoords }
export function useCurrentLocation() {
// Track status, not just coords — so the UI can tell "still loading" apart
// from "permission denied" and show an actionable message.
const [state, setState] = useState<LocationState>({ status: 'loading' })
useEffect(() => {
let active = true
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
if (active) setState({ status: 'denied' })
return
}
const pos = await Location.getCurrentPositionAsync({})
if (active) setState({ status: 'granted', coords: pos.coords })
})()
return () => { active = false } // ignore stale result after unmount
}, [])
return state
}将Expo SDK调用和订阅放在hooks中,而非JSX内。务必清理副作用。
use*tsx
import { useEffect, useState } from 'react'
import * as Location from 'expo-location'
type LocationState =
| { status: 'loading' }
| { status: 'denied' }
| { status: 'granted'; coords: Location.LocationObjectCoords }
export function useCurrentLocation() {
// 跟踪状态,而非仅坐标——这样UI可以区分"仍在加载"和"权限被拒绝",并显示可操作的提示信息。
const [state, setState] = useState<LocationState>({ status: 'loading' })
useEffect(() => {
let active = true
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
if (active) setState({ status: 'denied' })
return
}
const pos = await Location.getCurrentPositionAsync({})
if (active) setState({ status: 'granted', coords: pos.coords })
})()
return () => { active = false } // 卸载后忽略过时结果
}, [])
return state
}Secure storage for tokens
使用安全存储保存令牌
tsx
import * as SecureStore from 'expo-secure-store'
await SecureStore.setItemAsync('auth_token', token) // Keychain / Keystore
const token = await SecureStore.getItemAsync('auth_token')tsx
import * as SecureStore from 'expo-secure-store'
await SecureStore.setItemAsync('auth_token', token) // 钥匙串/密钥库
const token = await SecureStore.getItemAsync('auth_token')Code Examples
代码示例
A full screen: route → query → list → states
完整页面:路由 → 查询 → 列表 → 状态
tsx
// app/(tabs)/orders.tsx
import { memo, useCallback } from 'react'
import { FlatList, Text, View } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() })
const OrdersSchema = z.array(OrderSchema)
type Order = z.infer<typeof OrderSchema>
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: async () => OrdersSchema.parse(await api.listOrders()),
})
}
// Memoized so its reference is stable across renders (see the lists guidance).
const OrderRow = memo(function OrderRow({ item }: { item: Order }) {
return (
<View className="px-4 py-3 border-b border-neutral-200">
<Text className="font-medium">#{item.id}</Text>
<Text className="text-neutral-500">{item.status} · ${item.total}</Text>
</View>
)
})
export default function OrdersScreen() {
const { data, isLoading, isError, refetch, isRefetching } = useOrders()
const renderItem = useCallback(({ item }: { item: Order }) => <OrderRow item={item} />, [])
if (isLoading) return <Centered><Text>Loading…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">Could not load orders.</Text></Centered>
if (!data?.length) return <Centered><Text>No orders yet.</Text></Centered>
return (
<FlatList
data={data}
keyExtractor={(o) => o.id}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={renderItem}
/>
)
}tsx
// app/(tabs)/orders.tsx
import { memo, useCallback } from 'react'
import { FlatList, Text, View } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() })
const OrdersSchema = z.array(OrderSchema)
type Order = z.infer<typeof OrderSchema>
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: async () => OrdersSchema.parse(await api.listOrders()),
})
}
// 已记忆化,确保其引用在渲染过程中保持稳定(参考列表相关指南)。
const OrderRow = memo(function OrderRow({ item }: { item: Order }) {
return (
<View className="px-4 py-3 border-b border-neutral-200">
<Text className="font-medium">#{item.id}</Text>
<Text className="text-neutral-500">{item.status} · ${item.total}</Text>
</View>
)
})
export default function OrdersScreen() {
const { data, isLoading, isError, refetch, isRefetching } = useOrders()
const renderItem = useCallback(({ item }: { item: Order }) => <OrderRow item={item} />, [])
if (isLoading) return <Centered><Text>加载中…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">无法加载订单。</Text></Centered>
if (!data?.length) return <Centered><Text>暂无订单。</Text></Centered>
return (
<FlatList
data={data}
keyExtractor={(o) => o.id}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={renderItem}
/>
)
}A form: React Hook Form + Zod resolver
表单:React Hook Form + Zod解析器
tsx
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Button, Text } from 'react-native'
const Schema = z.object({ email: z.string().email('Invalid email') })
type FormValues = z.infer<typeof Schema>
export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '' },
})
return (
<>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="Email address"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="Save" onPress={handleSubmit(onSubmit)} />
</>
)
}tsx
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Button, Text } from 'react-native'
const Schema = z.object({ email: z.string().email('无效邮箱') })
type FormValues = z.infer<typeof Schema>
export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '' },
})
return (
<>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="邮箱地址"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="保存" onPress={handleSubmit(onSubmit)} />
</>
)
}Anti-Patterns
反模式
tsx
// WRONG: large array mapped inside a ScrollView (no virtualization, janky, high memory)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// RIGHT: FlatList / FlashList
// WRONG: server data copied into a client store (two sources of truth, stale data)
const useStore = create((set) => ({ users: [], setUsers: (u) => set({ users: u }) }))
useEffect(() => { getUsers().then(setUsers) }, [])
// RIGHT: useQuery owns server state; derive what you need
// WRONG: tokens in AsyncStorage (not encrypted)
await AsyncStorage.setItem('auth_token', token)
// RIGHT: expo-secure-store
// WRONG: trusting deep-link params
const { id } = useLocalSearchParams(); fetchUser(id)
// RIGHT: validate with Zod before use
// WRONG: inline style object recreated every render on a hot path
<View style={{ padding: 16, backgroundColor: '#fff' }} />
// RIGHT: StyleSheet.create at module scope, or NativeWind className
// WRONG: real secret shipped in the bundle
const STRIPE_SECRET = 'sk_live_...'
// RIGHT: keep privileged calls server-side; ship only public keys protected by backend rulestsx
// 错误:在ScrollView中直接映射大数组(无虚拟化,卡顿,内存占用高)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// 正确:使用FlatList / FlashList
// 错误:将服务端数据复制到客户端存储(存在两个数据源,数据易过时)
const useStore = create((set) => ({ users: [], setUsers: (u) => set({ users: u }) }))
useEffect(() => { getUsers().then(setUsers) }, [])
// 正确:由useQuery管理服务端状态;按需派生所需数据
// 错误:将令牌存储在AsyncStorage中(未加密)
await AsyncStorage.setItem('auth_token', token)
// 正确:使用expo-secure-store
// 错误:信任深度链接参数
const { id } = useLocalSearchParams(); fetchUser(id)
// 正确:使用Zod校验后再使用
// 错误:在热路径的渲染中每次都重新创建内联样式对象
<View style={{ padding: 16, backgroundColor: '#fff' }} />
// 正确:在模块作用域使用StyleSheet.create,或使用NativeWind className
// 错误:将真实密钥打包在应用包中
const STRIPE_SECRET = 'sk_live_...'
// 正确:将特权调用放在服务端;仅打包受后端规则保护的公钥Best Practices
最佳实践
- Keep route files thin; put logic in screen components and hooks.
use* - Validate every external input (API responses, route params, push payloads) with Zod.
- Let TanStack Query own server state; keep client stores small.
- Always render loading, error, and empty states — never just a spinner with no fallback.
- Virtualize lists; memoize ; provide a stable
renderItem.keyExtractor - Use for animation (UI thread); avoid heavy work on the JS thread.
react-native-reanimated - Store tokens in ; never trust the client for authorization.
expo-secure-store - Respect safe areas, Dynamic Type, and accessibility roles/labels from the start.
- Confirm New Architecture compatibility for every native dependency before release.
- 保持路由文件简洁;将逻辑放在页面组件和hooks中。
use* - 使用Zod校验所有外部输入(API响应、路由参数、推送负载)。
- 由TanStack Query管理服务端状态;保持客户端存储轻量化。
- 始终渲染加载、错误和空状态——切勿仅显示加载指示器而无降级方案。
- 对列表进行虚拟化;记忆化;提供稳定的
renderItem。keyExtractor - 使用实现动画(在UI线程执行);避免在JS线程执行繁重任务。
react-native-reanimated - 将令牌存储在中;切勿信任客户端进行授权。
expo-secure-store - 从项目初期就重视安全区域、动态字体和无障碍角色/标签。
- 发布前确认每个原生依赖都兼容新架构。
Related Skills
相关技能
- — React/Next.js (web) patterns; useful for shared React concepts, but DOM-specific.
frontend-patterns - — TypeScript/JavaScript idioms that apply to RN code.
coding-standards - ,
tdd-workflow— testing process (use Jest + React Native Testing Library, Maestro/Detox for RN).e2e-testing - — general security checklist that complements the RN bundle/secret guidance above.
security-review
- —— React/Next.js(Web)模式;适用于共享React概念,但DOM相关内容不适用。
frontend-patterns - —— 适用于RN代码的TypeScript/JavaScript规范。
coding-standards - ,
tdd-workflow—— 测试流程(RN使用Jest + React Native Testing Library,Maestro/Detox进行端到端测试)。e2e-testing - —— 通用安全检查清单,可补充上述RN包/密钥相关指南。
security-review