React Native Patterns (Expo Managed + NativeWind v5)
Purpose
Production patterns for the vendor mobile app built with React Native and Expo managed workflow. Covers list rendering performance, Reanimated animations, navigation with Expo Router, NativeWind v5 styling, native module usage, state management, and platform-specific patterns.
Core Rules
Expo Managed Workflow
- Stay within managed workflow — do not eject unless a native module is absolutely unavailable via Expo SDK or a community Expo module. Ejecting breaks OTA updates.
- Use
expo-dev-client for development builds with custom native modules — not bare workflow.
- OTA updates via Expo Updates — keep JS bundle small. Native changes require a new build.
- App configuration in
app.json / app.config.ts. No manual Info.plist or AndroidManifest.xml edits in managed workflow.
- Target
expo@latest SDK. Pin the SDK version explicitly — do not use *.
List Rendering (CRITICAL Performance)
- Always use
FlashList (from @shopify/flash-list) instead of FlatList or ScrollView for any list with more than 10 items.
FlashList requires estimatedItemSize — measure a real item, do not guess.
- Memoize list items: wrap item component in
React.memo. Ensure props are stable (no inline objects/functions).
- Stable callbacks: define
renderItem, keyExtractor, onEndReached outside the component or with useCallback — unstable functions cause full list re-renders.
- Avoid inline objects in
renderItem: renderItem={({ item }) => <Item data={item} />} is fine; renderItem={({ item }) => <Item style={{ padding: 8 }} />} re-renders every scroll.
- Images in lists: use
expo-image (not Image from React Native) — it caches, handles blurhash, and recycles efficiently.
- Never use
ScrollView + map() for potentially long lists. It renders all items at once.
Animations (Reanimated 3)
- Use React Native Reanimated 3 (
react-native-reanimated) for all animations.
- GPU-friendly properties only:
transform (translateX, translateY, scale, rotate), opacity. Never animate width, height, top, left, margin, padding — these trigger layout recalculation on every frame.
- Derived values:
useDerivedValue to compute animation values from shared values — runs on the UI thread, not JS thread.
- Gestures:
react-native-gesture-handler with GestureDetector. Never use onPress on Animated.View — use Pressable or GestureDetector.
- Shared values:
useSharedValue for values driven by gestures or timers. useAnimatedStyle to map shared values to style.
withSpring, withTiming for declarative animations. Avoid Animated.loop with Reanimated — use withRepeat.
Navigation (Expo Router)
- Use Expo Router (file-based, built on React Navigation) — not bare React Navigation.
- Route files in
app/ directory — mirrors Next.js App Router conventions.
- Dynamic routes:
app/listings/[id].tsx.
- Tab navigation:
app/(tabs)/_layout.tsx with <Tabs> from Expo Router.
- Stack navigation:
app/(stack)/_layout.tsx with <Stack>.
- Deep linking configured via
app.json scheme field — automatically handled by Expo Router.
- Use native navigators (
native prop) — renders platform-native navigation chrome (UINavigationController on iOS, Fragment on Android).
- Never use
router.back() for predictable navigation — use router.push() or router.replace() with explicit paths.
NativeWind v5 Styling
- NativeWind v5 + Tailwind CSS v4 — no Babel configuration needed (Metro transformer).
- Import platform-aware components from
react-native-css wrapper: View, Text, ScrollView, Pressable, TextInput, Image, Link.
className prop on these wrapped components — same Tailwind classes as web.
- Platform-specific styles via CSS media queries:
@media (platform: ios) { ... }, @media (platform: android) { ... }.
- Custom theme variables in
src/global.css via @theme block.
- Apple semantic colours:
light-dark() function in CSS for automatic dark mode. Use platformColor() for native system colours (e.g., iOS systemBlue).
tailwind-merge and clsx for conditional class composition — same as web pattern.
- Never use React Native's
StyleSheet.create alongside NativeWind on the same component. Pick one approach per component.
State Management
- Zustand for global state (auth session, user preferences, offline queue).
- TanStack Query for server state — auto-caches, handles background refresh, works offline with
networkMode: 'offlineFirst'.
- Local component state (
useState) for UI state that doesn't escape the component.
- Never use Context for frequently-updating state — causes wide re-renders. Use Zustand.
- Offline queue: persist pending mutations in Zustand (with MMKV storage) and replay on reconnect.
Platform-Specific Code
- Platform-specific file extensions:
Component.ios.tsx / Component.android.tsx for significantly different implementations.
Platform.OS === 'ios' / Platform.OS === 'android' for minor inline differences only. Excessive inline checks are a code smell.
- Safe area: always use
useSafeAreaInsets() or SafeAreaView from react-native-safe-area-context. Never hardcode status bar heights.
- Keyboard:
KeyboardAvoidingView with behavior="padding" (iOS) / behavior="height" (Android).
Native APIs (Expo SDK)
- Camera:
expo-camera. Location: expo-location. Notifications: expo-notifications. File system: expo-file-system.
- Always request permissions before accessing native APIs. Handle
denied and undetermined states explicitly — show explanatory UI before the permission prompt.
- Biometrics:
expo-local-authentication for secure local authentication.
- Storage:
expo-secure-store for sensitive data (tokens). @react-native-async-storage/async-storage or MMKV for non-sensitive persistent data.
Performance Rules
- No
console.log in production — remove or gate behind __DEV__.
- Avoid anonymous functions in JSX (
onPress={() => handler()}) in list items — breaks memo. Extract to named handlers.
InteractionManager.runAfterInteractions for expensive operations after navigation animations complete.
useFocusEffect instead of useEffect for screen-level data refresh — runs only when screen is focused.
Patterns / Examples
FlashList with memoised item
import { FlashList } from '@shopify/flash-list';
import { memo, useCallback } from 'react';
const ListingItem = memo(({ item, onPress }: { item: Listing; onPress: (id: string) => void }) => (
<Pressable className="p-4 border-b border-border" => onPress(item.id)}>
<Text className="text-base font-semibold text-foreground">{item.title}</Text>
<Text className="text-sm text-muted-foreground">{item.price}</Text>
</Pressable>
));
export function ListingsList({ listings }: { listings: Listing[] }) {
const router = useRouter();
const handlePress = useCallback((id: string) => router.push(`/listings/${id}`), [router]);
return (
<FlashList
data={listings}
renderItem={({ item }) => <ListingItem item={item} />}
estimatedItemSize={72}
keyExtractor={item => item.id}
/>
);
}
Swipe-to-action animation (Reanimated + Gesture Handler)
import { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
export function SwipeableListItem({ children, onDelete }: Props) {
const translateX = useSharedValue(0);
const panGesture = Gesture.Pan()
.onUpdate(e => { translateX.value = Math.min(0, e.translationX); })
.onEnd(e => {
if (e.translationX < -80) { runOnJS(onDelete)(); }
else { translateX.value = withSpring(0); }
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={animatedStyle}>{children}</Animated.View>
</GestureDetector>
);
}
NativeWind + platform colour
// src/global.css
@theme {
--color-primary: platformColor(systemBlue, #3b82f6);
--color-background: light-dark(#ffffff, #0a0a0a);
}
// Component
<View className="bg-background p-4">
<Text className="text-primary font-bold">Hello</Text>
</View>
When to Use
- Any vendor mobile app screen, component, or navigation flow
- List rendering, animations, gestures in the mobile app
- Expo SDK API usage (camera, location, notifications)
- NativeWind styling decisions
- Any mobile feature implementation or mobile story
When NOT to Use
- Customer portal (Next.js — see
nextjs-patterns)
- Admin SPA (React + Vite — see
react-admin-patterns)
- Web-specific patterns (CSS Grid, DOM APIs, SSR)
Source: ajorobert/SpecPipeline — distributed by TomeVault.
1---2name: ajorobert-specpipeline-react-native-patterns3description: React Native Patterns (Expo Managed + NativeWind v5)4---56# React Native Patterns (Expo Managed + NativeWind v5)78## Purpose9Production patterns for the vendor mobile app built with React Native and Expo managed workflow. Covers list rendering performance, Reanimated animations, navigation with Expo Router, NativeWind v5 styling, native module usage, state management, and platform-specific patterns.1011## Core Rules1213### Expo Managed Workflow14* Stay within managed workflow — do not eject unless a native module is absolutely unavailable via Expo SDK or a community Expo module. Ejecting breaks OTA updates.15* Use `expo-dev-client` for development builds with custom native modules — not bare workflow.16* OTA updates via Expo Updates — keep JS bundle small. Native changes require a new build.17* App configuration in `app.json` / `app.config.ts`. No manual `Info.plist` or `AndroidManifest.xml` edits in managed workflow.18* Target `expo@latest` SDK. Pin the SDK version explicitly — do not use `*`.1920### List Rendering (CRITICAL Performance)21* **Always use `FlashList`** (from `@shopify/flash-list`) instead of `FlatList` or `ScrollView` for any list with more than 10 items.22* `FlashList` requires `estimatedItemSize` — measure a real item, do not guess.23* Memoize list items: wrap item component in `React.memo`. Ensure props are stable (no inline objects/functions).24* Stable callbacks: define `renderItem`, `keyExtractor`, `onEndReached` outside the component or with `useCallback` — unstable functions cause full list re-renders.25* Avoid inline objects in `renderItem`: `renderItem={({ item }) => <Item data={item} />}` is fine; `renderItem={({ item }) => <Item style={{ padding: 8 }} />}` re-renders every scroll.26* Images in lists: use `expo-image` (not `Image` from React Native) — it caches, handles `blurhash`, and recycles efficiently.27* Never use `ScrollView` + `map()` for potentially long lists. It renders all items at once.2829### Animations (Reanimated 3)30* Use **React Native Reanimated 3** (`react-native-reanimated`) for all animations.31* **GPU-friendly properties only**: `transform` (translateX, translateY, scale, rotate), `opacity`. Never animate `width`, `height`, `top`, `left`, `margin`, `padding` — these trigger layout recalculation on every frame.32* Derived values: `useDerivedValue` to compute animation values from shared values — runs on the UI thread, not JS thread.33* Gestures: `react-native-gesture-handler` with `GestureDetector`. Never use `onPress` on `Animated.View` — use `Pressable` or `GestureDetector`.34* Shared values: `useSharedValue` for values driven by gestures or timers. `useAnimatedStyle` to map shared values to style.35* `withSpring`, `withTiming` for declarative animations. Avoid `Animated.loop` with Reanimated — use `withRepeat`.3637### Navigation (Expo Router)38* Use **Expo Router** (file-based, built on React Navigation) — not bare React Navigation.39* Route files in `app/` directory — mirrors Next.js App Router conventions.40* Dynamic routes: `app/listings/[id].tsx`.41* Tab navigation: `app/(tabs)/_layout.tsx` with `<Tabs>` from Expo Router.42* Stack navigation: `app/(stack)/_layout.tsx` with `<Stack>`.43* Deep linking configured via `app.json` `scheme` field — automatically handled by Expo Router.44* Use native navigators (`native` prop) — renders platform-native navigation chrome (UINavigationController on iOS, Fragment on Android).45* Never use `router.back()` for predictable navigation — use `router.push()` or `router.replace()` with explicit paths.4647### NativeWind v5 Styling48* NativeWind v5 + Tailwind CSS v4 — no Babel configuration needed (Metro transformer).49* Import platform-aware components from `react-native-css` wrapper: `View`, `Text`, `ScrollView`, `Pressable`, `TextInput`, `Image`, `Link`.50* `className` prop on these wrapped components — same Tailwind classes as web.51* Platform-specific styles via CSS media queries: `@media (platform: ios) { ... }`, `@media (platform: android) { ... }`.52* Custom theme variables in `src/global.css` via `@theme` block.53* Apple semantic colours: `light-dark()` function in CSS for automatic dark mode. Use `platformColor()` for native system colours (e.g., iOS `systemBlue`).54* `tailwind-merge` and `clsx` for conditional class composition — same as web pattern.55* Never use React Native's `StyleSheet.create` alongside NativeWind on the same component. Pick one approach per component.5657### State Management58* Zustand for global state (auth session, user preferences, offline queue).59* TanStack Query for server state — auto-caches, handles background refresh, works offline with `networkMode: 'offlineFirst'`.60* Local component state (`useState`) for UI state that doesn't escape the component.61* Never use Context for frequently-updating state — causes wide re-renders. Use Zustand.62* Offline queue: persist pending mutations in Zustand (with MMKV storage) and replay on reconnect.6364### Platform-Specific Code65* Platform-specific file extensions: `Component.ios.tsx` / `Component.android.tsx` for significantly different implementations.66* `Platform.OS === 'ios'` / `Platform.OS === 'android'` for minor inline differences only. Excessive inline checks are a code smell.67* Safe area: always use `useSafeAreaInsets()` or `SafeAreaView` from `react-native-safe-area-context`. Never hardcode status bar heights.68* Keyboard: `KeyboardAvoidingView` with `behavior="padding"` (iOS) / `behavior="height"` (Android).6970### Native APIs (Expo SDK)71* Camera: `expo-camera`. Location: `expo-location`. Notifications: `expo-notifications`. File system: `expo-file-system`.72* Always request permissions before accessing native APIs. Handle `denied` and `undetermined` states explicitly — show explanatory UI before the permission prompt.73* Biometrics: `expo-local-authentication` for secure local authentication.74* Storage: `expo-secure-store` for sensitive data (tokens). `@react-native-async-storage/async-storage` or MMKV for non-sensitive persistent data.7576### Performance Rules77* No `console.log` in production — remove or gate behind `__DEV__`.78* Avoid anonymous functions in JSX (`onPress={() => handler()}`) in list items — breaks `memo`. Extract to named handlers.79* `InteractionManager.runAfterInteractions` for expensive operations after navigation animations complete.80* `useFocusEffect` instead of `useEffect` for screen-level data refresh — runs only when screen is focused.8182## Patterns / Examples8384### FlashList with memoised item85```tsx86import { FlashList } from '@shopify/flash-list';87import { memo, useCallback } from 'react';8889const ListingItem = memo(({ item, onPress }: { item: Listing; onPress: (id: string) => void }) => (90 <Pressable className="p-4 border-b border-border" onPress={() => onPress(item.id)}>91 <Text className="text-base font-semibold text-foreground">{item.title}</Text>92 <Text className="text-sm text-muted-foreground">{item.price}</Text>93 </Pressable>94));9596export function ListingsList({ listings }: { listings: Listing[] }) {97 const router = useRouter();98 const handlePress = useCallback((id: string) => router.push(`/listings/${id}`), [router]);99100 return (101 <FlashList102 data={listings}103 renderItem={({ item }) => <ListingItem item={item} onPress={handlePress} />}104 estimatedItemSize={72}105 keyExtractor={item => item.id}106 />107 );108}109```110111### Swipe-to-action animation (Reanimated + Gesture Handler)112```tsx113import { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';114import { GestureDetector, Gesture } from 'react-native-gesture-handler';115116export function SwipeableListItem({ children, onDelete }: Props) {117 const translateX = useSharedValue(0);118119 const panGesture = Gesture.Pan()120 .onUpdate(e => { translateX.value = Math.min(0, e.translationX); })121 .onEnd(e => {122 if (e.translationX < -80) { runOnJS(onDelete)(); }123 else { translateX.value = withSpring(0); }124 });125126 const animatedStyle = useAnimatedStyle(() => ({127 transform: [{ translateX: translateX.value }],128 }));129130 return (131 <GestureDetector gesture={panGesture}>132 <Animated.View style={animatedStyle}>{children}</Animated.View>133 </GestureDetector>134 );135}136```137138### NativeWind + platform colour139```tsx140// src/global.css141@theme {142 --color-primary: platformColor(systemBlue, #3b82f6);143 --color-background: light-dark(#ffffff, #0a0a0a);144}145146// Component147<View className="bg-background p-4">148 <Text className="text-primary font-bold">Hello</Text>149</View>150```151152## When to Use153* Any vendor mobile app screen, component, or navigation flow154* List rendering, animations, gestures in the mobile app155* Expo SDK API usage (camera, location, notifications)156* NativeWind styling decisions157* Any mobile feature implementation or mobile story158159## When NOT to Use160* Customer portal (Next.js — see `nextjs-patterns`)161* Admin SPA (React + Vite — see `react-admin-patterns`)162* Web-specific patterns (CSS Grid, DOM APIs, SSR)163164---165> Source: [ajorobert/SpecPipeline](https://github.com/ajorobert/SpecPipeline) — distributed by [TomeVault](https://tomevault.io).166<!-- tomevault:4.0:skill_md:2026-05-22 -->