react-native-advanced

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

React Native Advanced: Expo + TanStack/XState/Zustand Ecosystem

React Native 进阶:Expo + TanStack/XState/Zustand 生态系统

React Native and Expo patterns for apps built with the TanStack ecosystem, XState, and Zustand. This skill extends
react-advanced
(core cross-platform patterns). Read that skill first for React Query, XState, Zustand, Zod, TanStack Form, and TanStack Table conventions.
基于TanStack生态、XState和Zustand构建的React Native和Expo应用开发模式。本技能是
react-advanced
(核心跨平台模式)的扩展内容。请先阅读该技能了解React Query、XState、Zustand、Zod、TanStack Form和TanStack Table的使用规范。

Table of Contents

目录

RN Architecture

RN架构

Libraries map differently in React Native compared to web:
Web LibraryRN EquivalentKey Difference
TanStack RouterExpo RouterNo route loaders on native, file-based navigation
TanStack StartNo SSR/server functions on native
TanStack VirtualFlashListNative view recycling, not DOM virtualization
localStorageMMKVSynchronous, native-thread, 30x faster
window eventsAppState/NetInfoManual wiring required for focus/online managers
CSS animationsReanimatedUI-thread worklets, CSS transitions (v4)
DOM eventsGesture HandlerGesture composition API, UI-thread callbacks
<img>
expo-imageSDWebImage/Glide, blurhash, disk caching
Web Push APIexpo-notificationsFCM/APNs, channels, background tasks
Service Workersexpo-updatesOTA updates, staged rollout, emergency rollback
Cross-platform libraries (identical API on web and RN): React Query, XState, Zustand, Zod, TanStack Form, TanStack Table

与Web端相比,React Native的类库的对应关系有所不同:
Web类库RN等价实现核心差异
TanStack RouterExpo Router原生端无路由加载器,采用基于文件的导航
TanStack Start原生端无SSR/服务端函数
TanStack VirtualFlashList原生视图回收,而非DOM虚拟化
localStorageMMKV同步执行、原生线程运行,速度快30倍
window eventsAppState/NetInfo焦点/在线状态管理器需要手动绑定
CSS animationsReanimatedUI线程工作流,支持CSS过渡(v4版本)
DOM eventsGesture Handler手势组合API,UI线程回调
<img>
expo-image基于SDWebImage/Glide实现,支持模糊哈希、磁盘缓存
Web Push APIexpo-notifications支持FCM/APNs、通知渠道、后台任务
Service Workersexpo-updatesOTA更新、分阶段发布、紧急回滚
跨平台类库(Web和RN端API完全一致): React Query, XState, Zustand, Zod, TanStack Form, TanStack Table

Required Setup

必填配置

These two integrations are mandatory — without them, React Query's auto-refetch and offline handling do not work in React Native.
以下两个集成是强制要求的——缺少它们的话,React Query的自动重新请求和离线处理功能在React Native中将无法正常工作。

focusManager — refetch when app returns to foreground

focusManager — 应用回到前台时重新请求数据

typescript
// hooks/useAppState.ts
import { useEffect } from "react";
import { AppState, Platform } from "react-native";
import type { AppStateStatus } from "react-native";
import { focusManager } from "@tanstack/react-query";

function onAppStateChange(status: AppStateStatus) {
  if (Platform.OS !== "web") {
    focusManager.setFocused(status === "active");
  }
}

export function useAppState() {
  useEffect(() => {
    const sub = AppState.addEventListener("change", onAppStateChange);
    return () => sub.remove();
  }, []);
}
typescript
// hooks/useAppState.ts
import { useEffect } from "react";
import { AppState, Platform } from "react-native";
import type { AppStateStatus } from "react-native";
import { focusManager } from "@tanstack/react-query";

function onAppStateChange(status: AppStateStatus) {
  if (Platform.OS !== "web") {
    focusManager.setFocused(status === "active");
  }
}

export function useAppState() {
  useEffect(() => {
    const sub = AppState.addEventListener("change", onAppStateChange);
    return () => sub.remove();
  }, []);
}

onlineManager — pause/resume queries based on network

onlineManager — 根据网络状态暂停/恢复查询

typescript
// hooks/useOnlineManager.ts
import { useEffect } from "react";
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";

export function useOnlineManager() {
  useEffect(() => {
    return NetInfo.addEventListener((state) => {
      onlineManager.setOnline(!!state.isConnected);
    });
  }, []);
}
Call both hooks once in the root layout:
typescript
// app/_layout.tsx
export default function RootLayout() {
  useAppState()
  useOnlineManager()
  return (
    <QueryClientProvider client={queryClient}>
      <Slot />
    </QueryClientProvider>
  )
}

typescript
// hooks/useOnlineManager.ts
import { useEffect } from "react";
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";

export function useOnlineManager() {
  useEffect(() => {
    return NetInfo.addEventListener((state) => {
      onlineManager.setOnline(!!state.isConnected);
    });
  }, []);
}
在根布局中调用这两个钩子一次:
typescript
// app/_layout.tsx
export default function RootLayout() {
  useAppState()
  useOnlineManager()
  return (
    <QueryClientProvider client={queryClient}>
      <Slot />
    </QueryClientProvider>
  )
}

Data Fetching Without Route Loaders

无路由加载器的数据请求

Expo Router has no native route loaders (data loaders are web-only/alpha). The pattern is: prefetch on user interaction, consume in the destination screen.
Expo Router没有原生路由加载器(数据加载器仅支持Web端/处于alpha阶段)。推荐模式为:用户交互时预取数据,在目标页面中使用数据。

Prefetch on press (don't await — keep navigation instant)

点击时预取(不要await —— 保持导航即时响应

typescript
function PostListItem({ id }: { id: string }) {
  const queryClient = useQueryClient()
  const router = useRouter()

  return (
    <Pressable
      onPress={() => {
        queryClient.prefetchQuery(postQueryOptions(id)) // fire-and-forget
        router.push(`/posts/${id}`)
      }}
    >
      <Text>{title}</Text>
    </Pressable>
  )
}
typescript
function PostListItem({ id }: { id: string }) {
  const queryClient = useQueryClient()
  const router = useRouter()

  return (
    <Pressable
      onPress={() => {
        queryClient.prefetchQuery(postQueryOptions(id)) // 触发后无需等待
        router.push(`/posts/${id}`)
      }}
    >
      <Text>{title}</Text>
    </Pressable>
  )
}

Refetch when screen regains focus

页面重新获得焦点时重新请求数据

Screens stay mounted in a native stack.
useEffect
does not re-run when navigating back. Use
useFocusEffect
to invalidate stale data:
typescript
import { useFocusEffect } from "expo-router";

export function useRefreshOnFocus(queryKey: unknown[]) {
  const queryClient = useQueryClient();
  const firstRender = useRef(true);

  useFocusEffect(
    useCallback(() => {
      if (firstRender.current) {
        firstRender.current = false;
        return;
      }
      queryClient.invalidateQueries({ queryKey });
    }, [queryClient, queryKey]),
  );
}
Use
invalidateQueries
(respects
staleTime
) instead of
refetch()
(always re-fetches).

原生导航栈中的页面会保持挂载状态,导航返回时
useEffect
不会重新执行。使用
useFocusEffect
来失效过期数据:
typescript
import { useFocusEffect } from "expo-router";

export function useRefreshOnFocus(queryKey: unknown[]) {
  const queryClient = useQueryClient();
  const firstRender = useRef(true);

  useFocusEffect(
    useCallback(() => {
      if (firstRender.current) {
        firstRender.current = false;
        return;
      }
      queryClient.invalidateQueries({ queryKey });
    }, [queryClient, queryKey]),
  );
}
推荐使用
invalidateQueries
(会尊重
staleTime
配置)而非
refetch()
(总是会重新请求)。

Navigation + Auth

导航 + 身份验证

Stack.Protected (Expo Router v5+, recommended)

Stack.Protected(Expo Router v5+ 推荐)

typescript
// app/_layout.tsx
import { Stack } from 'expo-router'
import { useAuthStore } from '@/stores/authStore'

export default function RootLayout() {
  const session = useAuthStore((s) => s.session)

  return (
    <Stack>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
      </Stack.Protected>
      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  )
}
When
session
flips, Expo Router automatically redirects and cleans history.
typescript
// app/_layout.tsx
import { Stack } from 'expo-router'
import { useAuthStore } from '@/stores/authStore'

export default function RootLayout() {
  const session = useAuthStore((s) => s.session)

  return (
    <Stack>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="modal" options={{ presentation: 'modal' }} />
      </Stack.Protected>
      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  )
}
session
状态变更时,Expo Router会自动重定向并清理历史记录。

With XState for complex auth flows

复杂身份验证流程搭配XState使用

XState manages async auth (token check, refresh, error recovery). Derive a boolean for
Stack.Protected
:
typescript
const AuthContext = createActorContext(authMachine)

function RootNavigator() {
  const session = AuthContext.useSelector((s) => s.context.session)
  const isChecking = AuthContext.useSelector((s) => s.matches('checking'))

  if (isChecking) return <SplashScreen />

  return (
    <Stack>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  )
}

XState用于管理异步身份验证流程(token检查、刷新、错误恢复)。可以从中派生出布尔值给
Stack.Protected
使用:
typescript
const AuthContext = createActorContext(authMachine)

function RootNavigator() {
  const session = AuthContext.useSelector((s) => s.context.session)
  const isChecking = AuthContext.useSelector((s) => s.matches('checking'))

  if (isChecking) return <SplashScreen />

  return (
    <Stack>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={!session}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  )
}

Lists: FlashList + React Query

列表:FlashList + React Query

FlashList replaces TanStack Virtual for RN — it uses native view recycling instead of DOM-based absolute positioning.
FlashList替代了RN中的TanStack Virtual——它使用原生视图回收机制,而非基于DOM的绝对定位。

Infinite scroll pattern

无限滚动模式

typescript
function PostList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isRefetching } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: ({ pageParam }) => fetchPosts(pageParam),
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    })

  const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data])

  const handleEndReached = useCallback(() => {
    if (hasNextPage && !isFetchingNextPage) fetchNextPage()
  }, [hasNextPage, isFetchingNextPage, fetchNextPage])

  return (
    <FlashList
      data={items}
      renderItem={({ item }) => <PostCard post={item} />}
      keyExtractor={(item) => item.id}
      onEndReached={handleEndReached}
      onEndReachedThreshold={0.3}
      ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
      refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
    />
  )
}
The
!isFetchingNextPage
guard in
handleEndReached
is essential — FlashList can fire
onEndReached
multiple times in quick succession, causing duplicate fetches.

typescript
function PostList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isRefetching } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: ({ pageParam }) => fetchPosts(pageParam),
      initialPageParam: 0,
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    })

  const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data])

  const handleEndReached = useCallback(() => {
    if (hasNextPage && !isFetchingNextPage) fetchNextPage()
  }, [hasNextPage, isFetchingNextPage, fetchNextPage])

  return (
    <FlashList
      data={items}
      renderItem={({ item }) => <PostCard post={item} />}
      keyExtractor={(item) => item.id}
      onEndReached={handleEndReached}
      onEndReachedThreshold={0.3}
      ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
      refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
    />
  )
}
handleEndReached
中的
!isFetchingNextPage
守卫是必不可少的——FlashList可能会在短时间内多次触发
onEndReached
,导致重复请求。

TanStack Form in RN

RN中的TanStack Form

TanStack Form is headless — no DOM dependency, no adapter needed. The key difference is
TextInput
uses
onChangeText
(string directly) instead of
onChange
(event object).
typescript
<form.Field name="username">
  {(field) => (
    <TextInput
      value={field.state.value}
      onChangeText={field.handleChange}   // string directly — no event extraction
      onBlur={field.handleBlur}           // triggers isTouched + onBlur validators
      autoCapitalize="none"
    />
  )}
</form.Field>
For numeric fields, convert at the call site:
typescript
<TextInput
  keyboardType="numeric"
  onChangeText={(val) => field.handleChange(val === '' ? null : Number(val))}
  value={String(field.state.value ?? '')}
/>
Wrap forms in
ScrollView
with
keyboardShouldPersistTaps="handled"
— otherwise the first tap on Submit dismisses the keyboard instead of firing the press.

TanStack Form是无UI的——没有DOM依赖,无需适配器。核心差异在于
TextInput
使用
onChangeText
(直接传入字符串)而非
onChange
(传入事件对象)。
typescript
<form.Field name="username">
  {(field) => (
    <TextInput
      value={field.state.value}
      onChangeText={field.handleChange}   // 直接传入字符串 —— 无需提取事件值
      onBlur={field.handleBlur}           // 触发isTouched状态和onBlur验证器
      autoCapitalize="none"
    />
  )}
</form.Field>
对于数字字段,在调用处转换类型:
typescript
<TextInput
  keyboardType="numeric"
  onChangeText={(val) => field.handleChange(val === '' ? null : Number(val))}
  value={String(field.state.value ?? '')}
/>
将表单包裹在设置了
keyboardShouldPersistTaps="handled"
ScrollView
中——否则第一次点击提交按钮会收起键盘而非触发点击事件。

Zustand Persist with MMKV

基于MMKV的Zustand持久化

MMKV is synchronous and runs on the native thread — no async hydration gap.
typescript
import { createMMKV } from "react-native-mmkv";
import { StateStorage, createJSONStorage } from "zustand/middleware";

const mmkv = createMMKV(); // create at module level — never inside a component

const zustandStorage: StateStorage = {
  setItem: (name, value) => mmkv.set(name, value),
  getItem: (name) => mmkv.getString(name) ?? null, // must return null, not undefined
  removeItem: (name) => mmkv.remove(name),
};

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      /* state + actions */
    }),
    {
      name: "app-storage",
      storage: createJSONStorage(() => zustandStorage),
      partialize: (state) => ({ token: state.token, theme: state.theme }),
    },
  ),
);
For sensitive data, use an encrypted MMKV instance with
encryptionKey
. For the hybrid pattern (hardware-backed key + encrypted MMKV), see
references/expo-essentials.md
.

MMKV是同步执行的,运行在原生线程——没有异步 hydration 间隙。
typescript
import { createMMKV } from "react-native-mmkv";
import { StateStorage, createJSONStorage } from "zustand/middleware";

const mmkv = createMMKV(); // 在模块级别创建 —— 永远不要在组件内部创建

const zustandStorage: StateStorage = {
  setItem: (name, value) => mmkv.set(name, value),
  getItem: (name) => mmkv.getString(name) ?? null, // 必须返回null,不能返回undefined
  removeItem: (name) => mmkv.remove(name),
};

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      /* 状态 + 操作 */
    }),
    {
      name: "app-storage",
      storage: createJSONStorage(() => zustandStorage),
      partialize: (state) => ({ token: state.token, theme: state.theme }),
    },
  ),
);
对于敏感数据,使用带
encryptionKey
的加密MMKV实例。混合模式(硬件支持的密钥 + 加密MMKV)请参考
references/expo-essentials.md

Animations & Gestures

动画与手势

Reanimated runs animations on the UI thread via worklets. Gesture Handler routes touch events to the same thread. Reanimated 4 adds CSS-style declarative transitions.
Reanimated通过工作流在UI线程运行动画。Gesture Handler将触摸事件路由到同一个线程。Reanimated 4新增了CSS风格的声明式过渡。

Threading Model

线程模型

Shared values live on the UI thread. Reading
.value
on the JS thread is a blocking bridge call — never do it in hot paths. Writing is instant.
typescript
const x = useSharedValue(0);

// Gesture callback — runs on UI thread (worklet)
const pan = Gesture.Pan()
  .onChange((e) => {
    "worklet";
    x.value += e.changeX; // instant, no bridge
  })
  .onEnd(() => {
    "worklet";
    x.value = withSpring(0);
    runOnJS(onDragEnd)(); // call JS functions via runOnJS
  });

// Animated style — also runs on UI thread
const style = useAnimatedStyle(() => ({
  transform: [{ translateX: x.value }],
}));
共享值存储在UI线程。在JS线程读取
.value
是阻塞的桥接调用——永远不要在热路径中执行。写入操作是即时的。
typescript
const x = useSharedValue(0);

// 手势回调 —— 在UI线程运行(工作流)
const pan = Gesture.Pan()
  .onChange((e) => {
    "worklet";
    x.value += e.changeX; // 即时执行,无桥接开销
  })
  .onEnd(() => {
    "worklet";
    x.value = withSpring(0);
    runOnJS(onDragEnd)(); // 通过runOnJS调用JS函数
  });

// 动画样式 —— 同样在UI线程运行
const style = useAnimatedStyle(() => ({
  transform: [{ translateX: x.value }],
}));

Critical Rules

核心规则

  • Replace entire values:
    sv.value = { x: 50 }
    not
    sv.value.x = 50
    (breaks reactivity)
  • No destructuring:
    const { x } = sv.value
    creates a plain number, not reactive
  • No reads during render: shared value reads are side effects, violate Rules of React
  • Shared values don't re-render: if you need React to respond, maintain separate state
  • GestureHandlerRootView must wrap the app root with
    style={{ flex: 1 }}
  • Android modals need their own
    GestureHandlerRootView
    (outside native root view)
  • 替换整个值: 使用
    sv.value = { x: 50 }
    而非
    sv.value.x = 50
    (后者会破坏响应式)
  • 不要解构:
    const { x } = sv.value
    会创建普通数字,失去响应式
  • 渲染期间不要读取: 读取共享值属于副作用,违反React规则
  • 共享值变更不会触发重渲染: 如果你需要React响应变更,请维护单独的状态
  • GestureHandlerRootView必须用
    style={{ flex: 1 }}
    包裹应用根节点
  • Android弹窗需要自己的
    GestureHandlerRootView
    (在原生根视图之外)

Declarative vs Imperative

声明式 vs 命令式

Use CSS transitions (Reanimated 4) for state-driven style changes (toggle colors, opacity, dimensions). Use layout animations (
entering
/
exiting
props) for mount/unmount. Use worklets + shared values for gesture-driven animations and imperative chains.
For detailed patterns, see
references/animations.md
.

使用CSS过渡(Reanimated 4)用于状态驱动的样式变更(切换颜色、透明度、尺寸)。使用布局动画
entering
/
exiting
属性)用于挂载/卸载动画。使用工作流 + 共享值用于手势驱动动画和命令式动画链。
详细模式请参考
references/animations.md

Bottom Sheets

底部弹窗

@lodev09/react-native-true-sheet
— a native bottom sheet backed by
UISheetPresentationController
(iOS) and
BottomSheetDialog
(Android). No Reanimated dependency. New Architecture only.
@lodev09/react-native-true-sheet
——原生底部弹窗,底层基于
UISheetPresentationController
(iOS)和
BottomSheetDialog
(Android)实现。无Reanimated依赖,仅支持新架构。

Imperative Ref-Based Control

命令式基于Ref的控制

typescript
import { TrueSheet } from "@lodev09/react-native-true-sheet";

function MySheet() {
  const sheet = useRef<TrueSheet>(null);

  return (
    <>
      <Button onPress={() => sheet.current?.present()} />
      <TrueSheet ref={sheet} detents={[0.5, 1]}>
        <MyContent />
      </TrueSheet>
    </>
  );
}
typescript
import { TrueSheet } from "@lodev09/react-native-true-sheet";

function MySheet() {
  const sheet = useRef<TrueSheet>(null);

  return (
    <>
      <Button onPress={() => sheet.current?.present()} />
      <TrueSheet ref={sheet} detents={[0.5, 1]}>
        <MyContent />
      </TrueSheet>
    </>
  );
}

Key Rules

核心规则

  • Imperative control only — use
    ref.present()
    /
    dismiss()
    /
    resize()
    , not state props.
  • Max 3 detents, sorted smallest to largest. Use
    'auto'
    for content-fitting.
  • Never combine
    scrollable
    with
    'auto'
    detent
    — they conflict. Use fixed detents.
  • Never use
    flex: 1
    on sheet content
    — collapses to zero height. Use
    flexGrow
    or fixed height.
  • Always
    dismiss()
    before unmounting
    — the native sheet outlives the React component.
  • Standard
    TextInput
    works
    — no special keyboard component needed (native handling).
  • Standard
    ScrollView
    /
    FlashList
    works
    with
    scrollable
    +
    nestedScrollEnabled
    — auto-detected in v3 (up to 2 levels deep).
For full patterns, platform differences, and Expo Router integration, see
references/bottom-sheet.md
.

  • 仅支持命令式控制 —— 使用
    ref.present()
    /
    dismiss()
    /
    resize()
    ,不要使用状态属性。
  • 最多3个停靠点,从小到大排序。使用
    'auto'
    可根据内容自动适配高度。
  • 永远不要将
    scrollable
    'auto'
    停靠点组合使用
    —— 二者会冲突,使用固定停靠点。
  • 永远不要在弹窗内容上使用
    flex: 1
    —— 会折叠为零高度,使用
    flexGrow
    或固定高度。
  • 卸载前必须调用
    dismiss()
    —— 原生弹窗的生命周期长于React组件。
  • 标准
    TextInput
    可直接使用
    —— 无需特殊键盘组件(原生处理)。
  • 标准
    ScrollView
    /
    FlashList
    可与
    scrollable
    +
    nestedScrollEnabled
    直接使用
    —— v3版本会自动检测(最多支持2层嵌套)。
完整模式、平台差异和Expo Router集成请参考
references/bottom-sheet.md

Push Notifications

推送通知

expo-notifications handles FCM (Android) and APNs (iOS) through Expo's push service.
expo-notifications通过Expo推送服务处理FCM(Android)和APNs(iOS)。

SDK 53 Breaking Changes

SDK 53 破坏性变更

  • Android push does not work in Expo Go — requires development build.
  • Config plugin must be explicit in
    app.json
    plugins
    array.
  • Android推送在Expo Go中无法工作 —— 需要开发构建版本。
  • 配置插件必须显式声明
    app.json
    plugins
    数组中。

Key Pattern

核心模式

typescript
// Must configure or foreground notifications are silently suppressed
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});
typescript
// 必须配置,否则前台通知会被静默抑制
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

Notification Rules

通知规则

  • Android channels must exist before requesting permissions on Android 13+.
  • Channels are immutable — cannot change importance/vibration after creation.
  • Poll receipts in production — tickets only confirm Expo received the request.
  • Background tasks must be defined at module scope (not inside components).
For permission handling, listeners, and background tasks, see
references/notifications.md
.

  • Android 13+ 必须在请求权限前创建通知渠道
  • 渠道是不可变的 —— 创建后无法更改重要性/振动配置。
  • 生产环境需要轮询回执 —— 票仅确认Expo收到了请求。
  • 后台任务必须在模块级别定义(不能在组件内部定义。
权限处理、监听器和后台任务请参考
references/notifications.md

OTA Updates

OTA更新

expo-updates enables over-the-air JavaScript bundle updates without app store releases.
expo-updates支持无需应用商店发布的JavaScript包空中更新。

Update Check Pattern

更新检查模式

typescript
import * as Updates from "expo-updates";

// Run after first render, not during startup (can freeze UI on slow network)
const check = await Updates.checkForUpdateAsync();
if (check.isAvailable) {
  await Updates.fetchUpdateAsync();
  await Updates.reloadAsync();
}
typescript
import * as Updates from "expo-updates";

// 在首次渲染后运行,不要在启动时运行(网络慢时会冻结UI)
const check = await Updates.checkForUpdateAsync();
if (check.isAvailable) {
  await Updates.fetchUpdateAsync();
  await Updates.reloadAsync();
}

Emergency Launch Detection

紧急启动检测

typescript
if (Updates.isEmergencyLaunch) {
  // OTA caused a crash — app rolled back to embedded bundle
  // Log immediately to error tracking
}
Always instrument this — it's your production crash signal for OTA updates.
For staged rollout, runtime versions, and reactive patterns, see
references/expo-essentials.md
.

typescript
if (Updates.isEmergencyLaunch) {
  // OTA导致崩溃 —— 应用回滚到内置包
  // 立即上报到错误跟踪系统
}
一定要添加这个检测——它是OTA更新生产崩溃的信号。
分阶段发布、运行时版本和响应式模式请参考
references/expo-essentials.md

File Organization

文件组织

text
app/
  _layout.tsx              # Root layout — providers, auth guard
  (auth)/
    _layout.tsx            # Auth group layout
    sign-in.tsx
  (app)/
    _layout.tsx            # App group layout (tabs)
    (tabs)/
      _layout.tsx          # Tab navigator
      index.tsx
      profile.tsx
    [id].tsx               # Dynamic route
    modal.tsx              # Modal screen
queries/                   # queryOptions definitions
mutations/                 # useMutation wrappers
machines/                  # XState machine definitions
stores/                    # Zustand stores (MMKV persist)
hooks/                     # useAppState, useOnlineManager, useRefreshOnFocus
components/                # Shared components
Key conventions:
  • Route groups
    (name)/
    organize without URL impact
  • _layout.tsx
    defines the navigator for each segment
  • Machine definitions are pure TypeScript — no React imports
  • queries/
    files export
    queryOptions
    objects, not hooks

text
app/
  _layout.tsx              # 根布局 —— 提供者、身份验证守卫
  (auth)/
    _layout.tsx            # 身份验证组布局
    sign-in.tsx
  (app)/
    _layout.tsx            # 应用组布局(标签栏)
    (tabs)/
      _layout.tsx          # 标签导航器
      index.tsx
      profile.tsx
    [id].tsx               # 动态路由
    modal.tsx              # 模态页面
queries/                   # queryOptions定义
mutations/                 # useMutation封装
machines/                  # XState状态机定义
stores/                    # Zustand状态(MMKV持久化
hooks/                     # useAppState, useOnlineManager, useRefreshOnFocus
components/                # 共享组件
核心规范:
  • 路由组
    (name)/
    用于组织代码,不会影响URL
  • _layout.tsx
    定义每个分段的导航器
  • 状态机定义是纯TypeScript —— 无React导入
  • queries/
    文件导出
    queryOptions
    对象,而非钩子

Common Pitfalls

常见陷阱

  1. Missing focusManager/onlineManager setup
    refetchOnWindowFocus
    and offline pausing do nothing without manual AppState and NetInfo wiring.
  2. Awaiting
    prefetchQuery
    before
    router.push
    — makes navigation feel slow. Fire prefetch without await, let React Query cache serve the destination screen.
  3. Using
    useGlobalSearchParams
    instead of
    useLocalSearchParams
    — global re-renders on every navigation event. Always prefer local.
  4. useEffect
    for screen focus refetch
    useEffect
    doesn't re-run when navigating back (screens stay mounted). Use
    useFocusEffect
    from
    expo-router
    .
  5. getNextPageParam
    returning
    null
    — must return
    undefined
    to signal no next page. Coerce API nulls:
    lastPage.nextCursor ?? undefined
    .
  6. FlashList v2 requires New Architecture — v2 is a ground-up rewrite for Fabric.
    estimatedItemSize
    is deprecated (ignored).
    overrideItemLayout
    only supports
    span
    .
  7. MMKV
    getString
    returns
    undefined
    — Zustand's
    StateStorage.getItem
    must return
    null
    for missing keys. Always coerce:
    ?? null
    .
  8. Creating MMKV instance inside a component — creates new instances each render. Declare at module level.
  9. keyboardShouldPersistTaps
    not set on form ScrollView
    — first tap dismisses keyboard instead of pressing Submit button.
  10. retry: 3
    (default) on mobile
    — with flaky connections, 3 retries with exponential backoff can take 30+ seconds. Consider
    retry: 1
    for time-sensitive UI.
  11. Mutating shared value properties
    sv.value.x = 50
    breaks reactivity. Must replace entire value:
    sv.value = { x: 50, y: 0 }
    .
  12. GestureHandlerRootView missing
    flex: 1
    — wrapper has zero height. Gestures appear to not work at all.
  13. Unmounting
    TrueSheet
    while open
    — the native sheet does NOT dismiss automatically. Always call
    dismiss()
    before removing from tree.
  14. auto
    detent with
    scrollable
    in TrueSheet
    — auto-sizing and scroll pinning conflict. Use fixed detents when
    scrollable={true}
    .
  15. Missing
    setNotificationHandler
    — all foreground notifications silently suppressed. No error.
  16. checkForUpdateAsync
    on slow network
    — can freeze Android UI for minutes. Run after first render with a manual timeout.
  17. expo-image
    recyclingKey
    in FlashList
    — without it, recycled cells flash the previous item's image.
  18. expo-secure-store biometric invalidation — keys with
    requireAuthentication
    become permanently unreadable when biometrics change. No recovery except clearing app data.

  1. 缺少focusManager/onlineManager配置 —— 没有手动绑定AppState和NetInfo的话,
    refetchOnWindowFocus
    和离线暂停功能不会生效。
  2. router.push
    前await
    prefetchQuery
    —— 会让导航感觉很慢。触发预取后无需等待,让React Query缓存为目标页面提供数据。
  3. 使用
    useGlobalSearchParams
    而非
    useLocalSearchParams
    —— 全局参数会在每次导航事件时重新渲染。优先使用本地参数。
  4. 使用
    useEffect
    实现页面聚焦重新请求
    —— 导航返回时
    useEffect
    不会重新执行(页面保持挂载)。使用
    expo-router
    useFocusEffect
  5. getNextPageParam
    返回
    null
    —— 必须返回
    undefined
    表示没有下一页。强制转换API返回的null值:
    lastPage.nextCursor ?? undefined
  6. **FlashList v2需要新架构支持 —— v2是为Fabric重写的版本。
    estimatedItemSize
    已废弃(会被忽略)。
    overrideItemLayout
    仅支持
    span
  7. MMKV
    getString
    返回
    undefined
    —— Zustand的
    StateStorage.getItem
    对于缺失的键必须返回
    null
    。始终强制转换:
    ?? null
  8. 在组件内部创建MMKV实例 —— 每次渲染都会创建新实例。在模块级别声明。
  9. 表单ScrollView未设置
    keyboardShouldPersistTaps
    —— 第一次点击会收起键盘而非触发提交按钮。
  10. 移动端默认
    retry: 3
    —— 网络不稳定时,3次指数退避重试可能需要30秒以上。对时间敏感的UI可以考虑
    retry: 1
  11. 修改共享值的属性 ——
    sv.value.x = 50
    会破坏响应式。必须替换整个值:
    sv.value = { x: 50, y: 0 }
  12. **GestureHandlerRootView缺少
    flex: 1
    —— 包裹层高度为零,手势看起来完全不工作。
  13. 打开状态下卸载
    TrueSheet
    —— 原生弹窗不会自动关闭。从DOM树移除前必须调用
    dismiss()
  14. TrueSheet中
    auto
    停靠点搭配
    scrollable
    —— 自动高度和滚动定位冲突。`scrollable={true}时使用固定停靠点。
  15. 缺少
    setNotificationHandler
    配置
    —— 所有前台通知会被静默抑制,没有错误提示。
  16. **网络慢时调用
    checkForUpdateAsync
    —— 可能会冻结Android UI数分钟。首次渲染后运行并添加手动超时。
  17. FlashList中expo-image缺少
    recyclingKey
    —— 没有该属性的话,回收的单元格会闪现上一个条目图片。
  18. **expo-secure-store生物识别失效 —— 设置了
    requireAuthentication
    的密钥在生物识别信息变更后会永久无法读取。除了清除应用数据没有恢复方法。

Reference Files

参考文件

FileWhen to read
references/react-query-rn.md
Focus/online managers, cache persistence, prefetching
references/expo-router.md
Typed routes, layouts, modals, auth, search params
references/lists.md
FlashList + React Query, infinite scroll, performance
references/zustand-rn.md
MMKV persist adapter, encryption, hydration patterns
references/testing-rn.md
RNTL, testing Query/Router/Form/XState, MSW in RN
references/animations.md
Reanimated, Gesture Handler patterns and gotchas
references/bottom-sheet.md
react-native-true-sheet setup, detents, Expo Router
references/notifications.md
expo-notifications permissions, listeners, background
references/expo-essentials.md
expo-image, expo-secure-store, expo-haptics, expo-updates
文件适用场景
references/react-query-rn.md
焦点/在线管理器、缓存持久化、预取
references/expo-router.md
类型路由、布局、模态框、身份验证、搜索参数
references/lists.md
FlashList + React Query、无限滚动、性能优化
references/zustand-rn.md
MMKV持久化适配器、加密、hydration模式
references/testing-rn.md
RNTL、测试Query/Router/Form/XState、RN中的MSW
references/animations.md
Reanimated、Gesture Handler模式和注意事项
references/bottom-sheet.md
react-native-true-sheet配置、停靠点、Expo Router
references/notifications.md
expo-notifications权限、监听器、后台任务
references/expo-essentials.md
expo-image、expo-secure-store、expo-haptics、expo-updates