react-native-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React 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
rules/react-native/
ruleset: rules say what to enforce, this skill shows how.
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
typescript/
rules.
These patterns assume the managed Expo workflow (Expo Router, EAS,
expo-*
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
<div>
, no URL bar, and no web data-fetching defaults.
这是一套使用Expo构建生产级React Native应用的实用模式,涵盖导航、状态管理、数据获取、列表、样式处理及原生API。与
rules/react-native/
规则集配套使用:规则集规定需要遵循的内容,本技能则展示具体实现方式。
下文提及的库(NativeWind、Zustand/Jotai、TanStack Query)是用于示例说明的主流成熟选择——模式本身比具体包更重要,任何同类替代方案均可适用。为与ECC现有
typescript/
规则保持一致,使用Zod进行校验。
这些模式基于Expo托管工作流(Expo Router、EAS、
expo-*
模块)及新架构(最新Expo SDK默认采用,SDK 55+强制要求)。它们不依赖浏览器DOM——React Native没有
<div>
、地址栏,也没有Web端默认的数据获取机制。

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
    app/
    directory)
  • 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,
<div>
, and SWR-for-browser do not apply to React Native.
在以下场景中使用本技能:
  • 构建或编辑React Native/Expo页面、组件或导航
  • 使用Expo Router(基于文件的
    app/
    目录)配置路由
  • 确定状态归属(服务端缓存 vs 客户端存储 vs 路由参数 vs 表单)
  • 结合TanStack Query实现数据获取并使用Zod校验响应
  • 渲染长列表或重型列表
  • 选择或应用样式方案(NativeWind或StyleSheet)
  • 访问原生设备API(相机、定位、通知)或安全存储
  • 审查RN代码中的移动端特定问题
请勿在此使用Web/React-DOM模式——基于URL的状态、
<div>
、浏览器端SWR等均不适用于React Native。

Core Concepts

核心概念

Project structure (Expo Router)

项目结构(Expo Router)

File-based routing under
app/
. Keep route files thin: they read and validate params, then delegate to a screen component that lives in
components/
or
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.tsx

Navigation: 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.
ConcernCommon choices
Server state (remote data)a server-cache library (TanStack Query, SWR)
Client/UI statea lightweight store (Zustand, Jotai) or Context
Route/navigation stateExpo Router params
Form statea form library (e.g. React Hook Form) + schema validation
Secrets / tokens
expo-secure-store
Non-secret persistence
AsyncStorage
/ MMKV
Prefer local
useState
until state genuinely needs sharing.
请勿将服务端数据复制到客户端存储中,每种状态都有其专属存储位置。
关注点常见选择
服务端状态(远程数据)服务端缓存库(TanStack Query、SWR)
客户端/UI状态轻量级存储(Zustand、Jotai)或Context
路由/导航状态Expo Router参数
表单状态表单库(如React Hook Form)+ schema校验
密钥/令牌
expo-secure-store
非机密持久化存储
AsyncStorage
/ MMKV
优先使用本地
useState
,直到状态确实需要共享时再更换方案。

Data fetching: a cache library + Zod

数据获取:缓存库 + Zod

Use a server-cache library (TanStack Query, SWR) instead of fetch-in-
useEffect
. Validate at the boundary and infer types from the schema. Handle loading, error, and empty states explicitly. (Example uses TanStack Query.)
tsx
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)替代
useEffect
中直接调用fetch。在边界处校验数据并从schema中推断类型。显式处理加载、错误和空状态。(示例使用TanStack Query。)
tsx
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
FlashList
(Shopify) for large or heterogeneous lists.
tsx
import { FlatList } from 'react-native'

<FlatList
  data={items}
  keyExtractor={(item) => item.id}
  renderItem={renderItem}          // 已记忆化
  initialNumToRender={10}
  windowSize={5}
/>
对于大型或异构列表,使用
FlashList
(Shopify出品)。

Styling: pick one system

样式:选择一套系统

StyleSheet.create()
is the framework-native option; utility-class libraries (e.g. NativeWind) are a common alternative. Choose one and stay consistent. Never build style objects inline in JSX on hot paths.
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()
是框架原生方案;工具类库(如NativeWind)是常见替代方案。选择其一并保持一致。切勿在热路径的JSX中内联构建样式对象。
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
use*
hooks, not in JSX. Always clean up.
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调用和订阅放在
use*
hooks中,而非JSX内。务必清理副作用。
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 rules
tsx
// 错误:在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
    use*
    hooks.
  • 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
    renderItem
    ; provide a stable
    keyExtractor
    .
  • Use
    react-native-reanimated
    for animation (UI thread); avoid heavy work on the JS thread.
  • Store tokens in
    expo-secure-store
    ; never trust the client for authorization.
  • Respect safe areas, Dynamic Type, and accessibility roles/labels from the start.
  • Confirm New Architecture compatibility for every native dependency before release.
  • 保持路由文件简洁;将逻辑放在页面组件和
    use*
    hooks中。
  • 使用Zod校验所有外部输入(API响应、路由参数、推送负载)。
  • 由TanStack Query管理服务端状态;保持客户端存储轻量化。
  • 始终渲染加载、错误和空状态——切勿仅显示加载指示器而无降级方案。
  • 对列表进行虚拟化;记忆化
    renderItem
    ;提供稳定的
    keyExtractor
  • 使用
    react-native-reanimated
    实现动画(在UI线程执行);避免在JS线程执行繁重任务。
  • 将令牌存储在
    expo-secure-store
    中;切勿信任客户端进行授权。
  • 从项目初期就重视安全区域、动态字体和无障碍角色/标签。
  • 发布前确认每个原生依赖都兼容新架构。

Related Skills

相关技能

  • frontend-patterns
    — React/Next.js (web) patterns; useful for shared React concepts, but DOM-specific.
  • coding-standards
    — TypeScript/JavaScript idioms that apply to RN code.
  • tdd-workflow
    ,
    e2e-testing
    — testing process (use Jest + React Native Testing Library, Maestro/Detox for RN).
  • security-review
    — general security checklist that complements the RN bundle/secret guidance above.
  • frontend-patterns
    —— React/Next.js(Web)模式;适用于共享React概念,但DOM相关内容不适用。
  • coding-standards
    —— 适用于RN代码的TypeScript/JavaScript规范。
  • tdd-workflow
    ,
    e2e-testing
    —— 测试流程(RN使用Jest + React Native Testing Library,Maestro/Detox进行端到端测试)。
  • security-review
    —— 通用安全检查清单,可补充上述RN包/密钥相关指南。