react-native-advanced
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact 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 (core cross-platform patterns). Read that skill
first for React Query, XState, Zustand, Zod, TanStack Form, and TanStack Table conventions.
react-advanced基于TanStack生态、XState和Zustand构建的React Native和Expo应用开发模式。本技能是(核心跨平台模式)的扩展内容。请先阅读该技能了解React Query、XState、Zustand、Zod、TanStack Form和TanStack Table的使用规范。
react-advancedTable of Contents
目录
RN Architecture
RN架构
Libraries map differently in React Native compared to web:
| Web Library | RN Equivalent | Key Difference |
|---|---|---|
| TanStack Router | Expo Router | No route loaders on native, file-based navigation |
| TanStack Start | — | No SSR/server functions on native |
| TanStack Virtual | FlashList | Native view recycling, not DOM virtualization |
| localStorage | MMKV | Synchronous, native-thread, 30x faster |
| window events | AppState/NetInfo | Manual wiring required for focus/online managers |
| CSS animations | Reanimated | UI-thread worklets, CSS transitions (v4) |
| DOM events | Gesture Handler | Gesture composition API, UI-thread callbacks |
| expo-image | SDWebImage/Glide, blurhash, disk caching |
| Web Push API | expo-notifications | FCM/APNs, channels, background tasks |
| Service Workers | expo-updates | OTA 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 Router | Expo Router | 原生端无路由加载器,采用基于文件的导航 |
| TanStack Start | — | 原生端无SSR/服务端函数 |
| TanStack Virtual | FlashList | 原生视图回收,而非DOM虚拟化 |
| localStorage | MMKV | 同步执行、原生线程运行,速度快30倍 |
| window events | AppState/NetInfo | 焦点/在线状态管理器需要手动绑定 |
| CSS animations | Reanimated | UI线程工作流,支持CSS过渡(v4版本) |
| DOM events | Gesture Handler | 手势组合API,UI线程回调 |
| expo-image | 基于SDWebImage/Glide实现,支持模糊哈希、磁盘缓存 |
| Web Push API | expo-notifications | 支持FCM/APNs、通知渠道、后台任务 |
| Service Workers | expo-updates | OTA更新、分阶段发布、紧急回滚 |
跨平台类库(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. does not re-run when navigating back.
Use to invalidate stale data:
useEffectuseFocusEffecttypescript
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 (respects ) instead of (always re-fetches).
invalidateQueriesstaleTimerefetch()原生导航栈中的页面会保持挂载状态,导航返回时不会重新执行。使用来失效过期数据:
useEffectuseFocusEffecttypescript
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]),
);
}推荐使用(会尊重配置)而非(总是会重新请求)。
invalidateQueriesstaleTimerefetch()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 flips, Expo Router automatically redirects and cleans history.
sessiontypescript
// 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>
)
}当状态变更时,Expo Router会自动重定向并清理历史记录。
sessionWith XState for complex auth flows
复杂身份验证流程搭配XState使用
XState manages async auth (token check, refresh, error recovery). Derive a boolean for
:
Stack.Protectedtypescript
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.Protectedtypescript
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 guard in is essential — FlashList can fire
multiple times in quick succession, causing duplicate fetches.
!isFetchingNextPagehandleEndReachedonEndReachedtypescript
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!isFetchingNextPageonEndReachedTanStack Form in RN
RN中的TanStack Form
TanStack Form is headless — no DOM dependency, no adapter needed. The key difference is
uses (string directly) instead of (event object).
TextInputonChangeTextonChangetypescript
<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 with — otherwise the
first tap on Submit dismisses the keyboard instead of firing the press.
ScrollViewkeyboardShouldPersistTaps="handled"TanStack Form是无UI的——没有DOM依赖,无需适配器。核心差异在于使用(直接传入字符串)而非(传入事件对象)。
TextInputonChangeTextonChangetypescript
<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"ScrollViewZustand 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 . For the hybrid
pattern (hardware-backed key + encrypted MMKV), see .
encryptionKeyreferences/expo-essentials.mdMMKV是同步执行的,运行在原生线程——没有异步 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 }),
},
),
);对于敏感数据,使用带的加密MMKV实例。混合模式(硬件支持的密钥 + 加密MMKV)请参考。
encryptionKeyreferences/expo-essentials.mdAnimations & 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 on the JS thread is a blocking bridge
call — never do it in hot paths. Writing is instant.
.valuetypescript
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线程读取是阻塞的桥接调用——永远不要在热路径中执行。写入操作是即时的。
.valuetypescript
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: not
sv.value = { x: 50 }(breaks reactivity)sv.value.x = 50 - No destructuring: creates a plain number, not reactive
const { x } = sv.value - 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 (outside native root view)
GestureHandlerRootView
- 替换整个值: 使用而非
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 (/ props) for mount/unmount.
Use worklets + shared values for gesture-driven animations and imperative chains.
enteringexitingFor detailed patterns, see .
references/animations.md使用CSS过渡(Reanimated 4)用于状态驱动的样式变更(切换颜色、透明度、尺寸)。使用布局动画(/属性)用于挂载/卸载动画。使用工作流 + 共享值用于手势驱动动画和命令式动画链。
enteringexiting详细模式请参考。
references/animations.mdBottom Sheets
底部弹窗
@lodev09/react-native-true-sheetUISheetPresentationControllerBottomSheetDialog@lodev09/react-native-true-sheetUISheetPresentationControllerBottomSheetDialogImperative 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(), not state props.resize() - Max 3 detents, sorted smallest to largest. Use for content-fitting.
'auto' - Never combine with
scrollabledetent — they conflict. Use fixed detents.'auto' - Never use on sheet content — collapses to zero height. Use
flex: 1or fixed height.flexGrow - Always before unmounting — the native sheet outlives the React component.
dismiss() - Standard works — no special keyboard component needed (native handling).
TextInput - Standard /
ScrollViewworks withFlashList+scrollable— auto-detected in v3 (up to 2 levels deep).nestedScrollEnabled
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 - 卸载前必须调用—— 原生弹窗的生命周期长于React组件。
dismiss() - 标准可直接使用 —— 无需特殊键盘组件(原生处理)。
TextInput - 标准/
ScrollView可与FlashList+scrollable直接使用 —— v3版本会自动检测(最多支持2层嵌套)。nestedScrollEnabled
完整模式、平台差异和Expo Router集成请参考。
references/bottom-sheet.mdPush 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.jsonarray.plugins
- 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.mdOTA 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.mdtypescript
if (Updates.isEmergencyLaunch) {
// OTA导致崩溃 —— 应用回滚到内置包
// 立即上报到错误跟踪系统
}一定要添加这个检测——它是OTA更新生产崩溃的信号。
分阶段发布、运行时版本和响应式模式请参考。
references/expo-essentials.mdFile 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 componentsKey conventions:
- Route groups organize without URL impact
(name)/ - defines the navigator for each segment
_layout.tsx - Machine definitions are pure TypeScript — no React imports
- files export
queries/objects, not hooksqueryOptions
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/ # 共享组件核心规范:
- 路由组用于组织代码,不会影响URL
(name)/ - 定义每个分段的导航器
_layout.tsx - 状态机定义是纯TypeScript —— 无React导入
- 文件导出
queries/对象,而非钩子queryOptions
Common Pitfalls
常见陷阱
-
Missing focusManager/onlineManager setup —and offline pausing do nothing without manual AppState and NetInfo wiring.
refetchOnWindowFocus -
Awaitingbefore
prefetchQuery— makes navigation feel slow. Fire prefetch without await, let React Query cache serve the destination screen.router.push -
Usinginstead of
useGlobalSearchParams— global re-renders on every navigation event. Always prefer local.useLocalSearchParams -
for screen focus refetch —
useEffectdoesn't re-run when navigating back (screens stay mounted). UseuseEffectfromuseFocusEffect.expo-router -
returning
getNextPageParam— must returnnullto signal no next page. Coerce API nulls:undefined.lastPage.nextCursor ?? undefined -
FlashList v2 requires New Architecture — v2 is a ground-up rewrite for Fabric.is deprecated (ignored).
estimatedItemSizeonly supportsoverrideItemLayout.span -
MMKVreturns
getString— Zustand'sundefinedmust returnStateStorage.getItemfor missing keys. Always coerce:null.?? null -
Creating MMKV instance inside a component — creates new instances each render. Declare at module level.
-
not set on form ScrollView — first tap dismisses keyboard instead of pressing Submit button.
keyboardShouldPersistTaps -
(default) on mobile — with flaky connections, 3 retries with exponential backoff can take 30+ seconds. Consider
retry: 3for time-sensitive UI.retry: 1 -
Mutating shared value properties —breaks reactivity. Must replace entire value:
sv.value.x = 50.sv.value = { x: 50, y: 0 } -
GestureHandlerRootView missing— wrapper has zero height. Gestures appear to not work at all.
flex: 1 -
Unmountingwhile open — the native sheet does NOT dismiss automatically. Always call
TrueSheetbefore removing from tree.dismiss() -
detent with
autoin TrueSheet — auto-sizing and scroll pinning conflict. Use fixed detents whenscrollable.scrollable={true} -
Missing— all foreground notifications silently suppressed. No error.
setNotificationHandler -
on slow network — can freeze Android UI for minutes. Run after first render with a manual timeout.
checkForUpdateAsync -
expo-imagein FlashList — without it, recycled cells flash the previous item's image.
recyclingKey -
expo-secure-store biometric invalidation — keys withbecome permanently unreadable when biometrics change. No recovery except clearing app data.
requireAuthentication
-
缺少focusManager/onlineManager配置 —— 没有手动绑定AppState和NetInfo的话,和离线暂停功能不会生效。
refetchOnWindowFocus -
前await
router.push—— 会让导航感觉很慢。触发预取后无需等待,让React Query缓存为目标页面提供数据。prefetchQuery -
使用而非
useGlobalSearchParams—— 全局参数会在每次导航事件时重新渲染。优先使用本地参数。useLocalSearchParams -
使用实现页面聚焦重新请求 —— 导航返回时
useEffect不会重新执行(页面保持挂载)。使用useEffect的expo-router。useFocusEffect -
返回
getNextPageParam—— 必须返回null表示没有下一页。强制转换API返回的null值:undefined。lastPage.nextCursor ?? undefined -
**FlashList v2需要新架构支持 —— v2是为Fabric重写的版本。已废弃(会被忽略)。
estimatedItemSize仅支持overrideItemLayout。span -
MMKV返回
getString—— Zustand的undefined对于缺失的键必须返回StateStorage.getItem。始终强制转换:null。?? null -
在组件内部创建MMKV实例 —— 每次渲染都会创建新实例。在模块级别声明。
-
表单ScrollView未设置—— 第一次点击会收起键盘而非触发提交按钮。
keyboardShouldPersistTaps -
移动端默认—— 网络不稳定时,3次指数退避重试可能需要30秒以上。对时间敏感的UI可以考虑
retry: 3。retry: 1 -
修改共享值的属性 ——会破坏响应式。必须替换整个值:
sv.value.x = 50。sv.value = { x: 50, y: 0 } -
**GestureHandlerRootView缺少—— 包裹层高度为零,手势看起来完全不工作。
flex: 1 -
打开状态下卸载—— 原生弹窗不会自动关闭。从DOM树移除前必须调用
TrueSheet。dismiss() -
TrueSheet中停靠点搭配
auto—— 自动高度和滚动定位冲突。`scrollable={true}时使用固定停靠点。scrollable -
缺少配置 —— 所有前台通知会被静默抑制,没有错误提示。
setNotificationHandler -
**网络慢时调用—— 可能会冻结Android UI数分钟。首次渲染后运行并添加手动超时。
checkForUpdateAsync -
FlashList中expo-image缺少—— 没有该属性的话,回收的单元格会闪现上一个条目图片。
recyclingKey -
**expo-secure-store生物识别失效 —— 设置了的密钥在生物识别信息变更后会永久无法读取。除了清除应用数据没有恢复方法。
requireAuthentication
Reference Files
参考文件
| File | When to read |
|---|---|
| Focus/online managers, cache persistence, prefetching |
| Typed routes, layouts, modals, auth, search params |
| FlashList + React Query, infinite scroll, performance |
| MMKV persist adapter, encryption, hydration patterns |
| RNTL, testing Query/Router/Form/XState, MSW in RN |
| Reanimated, Gesture Handler patterns and gotchas |
| react-native-true-sheet setup, detents, Expo Router |
| expo-notifications permissions, listeners, background |
| expo-image, expo-secure-store, expo-haptics, expo-updates |
| 文件 | 适用场景 |
|---|---|
| 焦点/在线管理器、缓存持久化、预取 |
| 类型路由、布局、模态框、身份验证、搜索参数 |
| FlashList + React Query、无限滚动、性能优化 |
| MMKV持久化适配器、加密、hydration模式 |
| RNTL、测试Query/Router/Form/XState、RN中的MSW |
| Reanimated、Gesture Handler模式和注意事项 |
| react-native-true-sheet配置、停靠点、Expo Router |
| expo-notifications权限、监听器、后台任务 |
| expo-image、expo-secure-store、expo-haptics、expo-updates |