Loading...
Loading...
React Native and Expo patterns for navigation, data fetching lifecycle, infinite scroll lists, form handling, state persistence, authentication routing, gesture-driven animations, bottom sheets, push notifications, and OTA updates. Use when building Expo/React Native apps that need screen-level data prefetching, auth guards with protected routes, infinite scroll feeds, native form input handling, offline-capable state persistence, platform-specific setup (focus/online managers), fluid animations and gesture interactions, modal bottom sheets, push notification flows, or over-the-air update strategies. Do not use for React web apps.
npx skill4agent add trancong12102/agentskills react-native-advancedreact-advanced| 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 |
// 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();
}, []);
}// 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);
});
}, []);
}// app/_layout.tsx
export default function RootLayout() {
useAppState()
useOnlineManager()
return (
<QueryClientProvider client={queryClient}>
<Slot />
</QueryClientProvider>
)
}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>
)
}useEffectuseFocusEffectimport { 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()// 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>
)
}sessionStack.Protectedconst 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>
)
}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} />}
/>
)
}!isFetchingNextPagehandleEndReachedonEndReachedTextInputonChangeTextonChange<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><TextInput
keyboardType="numeric"
onChangeText={(val) => field.handleChange(val === '' ? null : Number(val))}
value={String(field.state.value ?? '')}
/>ScrollViewkeyboardShouldPersistTaps="handled"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 }),
},
),
);encryptionKeyreferences/expo-essentials.md.valueconst 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 }],
}));sv.value = { x: 50 }sv.value.x = 50const { x } = sv.valuestyle={{ flex: 1 }}GestureHandlerRootViewenteringexitingreferences/animations.md@lodev09/react-native-true-sheetUISheetPresentationControllerBottomSheetDialogimport { 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>
</>
);
}ref.present()dismiss()resize()'auto'scrollable'auto'flex: 1flexGrowdismiss()TextInputScrollViewFlashListscrollablenestedScrollEnabledreferences/bottom-sheet.mdapp.jsonplugins// Must configure or foreground notifications are silently suppressed
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});references/notifications.mdimport * 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();
}if (Updates.isEmergencyLaunch) {
// OTA caused a crash — app rolled back to embedded bundle
// Log immediately to error tracking
}references/expo-essentials.mdapp/
_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(name)/_layout.tsxqueries/queryOptionsrefetchOnWindowFocusprefetchQueryrouter.pushuseGlobalSearchParamsuseLocalSearchParamsuseEffectuseEffectuseFocusEffectexpo-routergetNextPageParamnullundefinedlastPage.nextCursor ?? undefinedestimatedItemSizeoverrideItemLayoutspangetStringundefinedStateStorage.getItemnull?? nullkeyboardShouldPersistTapsretry: 3retry: 1sv.value.x = 50sv.value = { x: 50, y: 0 }flex: 1TrueSheetdismiss()autoscrollablescrollable={true}setNotificationHandlercheckForUpdateAsyncrecyclingKeyrequireAuthentication| 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 |