⏳
Loading cheatsheet...
Setup & CLI, Core Components, Styling, Navigation, State Management, Native Modules, Platform APIs, Performance — mobile development mastery.
# ── Create new React Native project ──
npx react-native@latest init MyApp --pm npm
npx react-native@latest init MyApp --template react-native-template-typescript
# ── Expo (recommended for most projects) ──
npx create-expo-app@latest MyApp --template blank-typescript
npx create-expo-app@latest MyApp --template tabs
# ── Run the app ──
# React Native CLI
npx react-native run-ios
npx react-native run-android
npx react-native start --reset-cache
# Expo
npx expo start
npx expo start --dev-client # development build
npx expo start --tunnel # expose via tunnel
npx expo start --lan # local network
# ── Expo build commands ──
npx expo install expo-camera # install expo module
npx expo export:web # export for web
eas build --platform ios # EAS Build (cloud)
eas build --platform android
eas update # OTA updates
# ── Run on specific device ──
npx react-native run-ios --device "iPhone 15"
npx react-native run-android --deviceId=EMULATOR_ID
adb devices # list Android devices/emulators| Path | Description |
|---|---|
| App.tsx | Root component / entry point |
| app/_layout.tsx | Expo Router root layout |
| android/ | Android native project |
| ios/ | iOS native project (Xcode) |
| metro.config.js | Metro bundler config |
| babel.config.js | Babel transpiler config |
| tsconfig.json | TypeScript configuration |
| package.json | Dependencies & scripts |
| eas.json | EAS Build configuration |
| app.json | Expo app configuration |
| Feature | RN CLI | Expo (Managed) |
|---|---|---|
| Setup | Complex | One command |
| Native Modules | Full access | Expo modules |
| Builds | Local (Xcode/AS) | EAS Build (cloud) |
| OTA Updates | CodePush | EAS Update |
| Custom Natives | Yes (Java/Kotlin) | Config plugins |
| Web Support | Partial | Full (Expo Router) |
| Learning Curve | Steep | Gentle |
| Best For | Complex apps | Most projects |
{
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"start": "react-native start",
"test": "jest",
"lint": "eslint ."
}
}npx expo install (not npm install) for Expo-compatible packages.import React, { useState } from 'react';
import {
View,
Text,
Image,
ScrollView,
FlatList,
TouchableOpacity,
SafeAreaView,
StatusBar,
StyleSheet,
ActivityIndicator,
TextInput,
Switch,
} from 'react-native';
// ── Basic Layout ──
function BasicScreen() {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<View style={styles.header}>
<Text style={styles.title}>Hello React Native</Text>
</View>
<ScrollView style={styles.content}>
<Text style={styles.body}>Scrollable content here...</Text>
</ScrollView>
</SafeAreaView>
);
}
// ── Image Component ──
function ImageExample() {
return (
<View>
{/* Local image */}
<Image
source={require('./assets/logo.png')}
style={styles.logo}
resizeMode="contain"
/>
{/* Remote image */}
<Image
source={{ uri: 'https://example.com/photo.jpg' }}
style={styles.remotePhoto}
resizeMode="cover"
defaultSource={require('./assets/placeholder.png')}
/>
</View>
);
}
// ── FlatList (optimized for long lists) ──
interface User {'{'} id: number; name: string; email: string; '{'}'}
function UserList() {
const [users, setUsers] = useState<User[]>([]);
const renderItem = ({'{'} item, index {'}'}: {'{'} item: User; index: number {'}'}) => (
<TouchableOpacity
style={[styles.card, index % 2 === 0 && styles.cardAlt]}
onPress={(){'{'} console.log('Pressed:', item.name) {'}'}}
activeOpacity={0.7}
>
<Text style={styles.name}>{'{'}item.name{'}'}</Text>
<Text style={styles.email}>{'{'}item.email{'}'}</Text>
</TouchableOpacity>
);
return (
<FlatList
data={users}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
ListEmptyComponent={<ActivityIndicator />}
ListHeaderComponent={<Text style={styles.header}>Users</Text>}
ListFooterComponent={<Text style={styles.footer}>End of list</Text>}
ItemSeparatorComponent={() => <View style={styles.separator} />}
onRefresh={() => fetchUsers()}
refreshing={false}
horizontal={false}
numColumns={1}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={10}
removeClippedSubviews={true}
/>
);
}
// ── SectionList ──
const sections = [
{'{'} title: 'Fruits', data: ['Apple', 'Banana', 'Cherry'] {'}'},
{'{'} title: 'Vegetables', data: ['Carrot', 'Broccoli'] {'}'},
];
function SectionListExample() {
return (
<SectionList
sections={sections}
renderItem={({'{'} item {'}'}) => (
<View style={styles.item}>
<Text>{'{'}item{'}'}</Text>
</View>
)}
renderSectionHeader={({'{'} section {'}'}) => (
<Text style={styles.sectionHeader}>{'{'}section.title{'}'}</Text>
)}
keyExtractor={(item) => item}
stickySectionHeadersEnabled={true}
/>
);
}| Component | Web Equivalent | Purpose |
|---|---|---|
| View | div | Container / layout |
| Text | span/p | Text display |
| Image | img | Image display |
| TextInput | input | Text input |
| ScrollView | scrollable div | General scrolling |
| FlatList | virtual list | Performant long lists |
| SectionList | grouped list | Grouped sections |
| TouchableOpacity | button | Touch feedback |
| SafeAreaView | none | Notch/status bar safe |
| StatusBar | none | Status bar config |
| ActivityIndicator | spinner | Loading indicator |
| Switch | toggle | Boolean switch |
| Modal | dialog | Modal overlay |
| KeyboardAvoidingView | none | Keyboard handling |
| Prop | Used On | Description |
|---|---|---|
| style | All | Inline styles or StyleSheet ref |
| onPress | Touchable | Touch event handler |
| accessibilityLabel | All | Screen reader text |
| accessible | All | Enable accessibility |
| hitSlop | Touchable | Expand touchable area |
| pointerEvents | View | Control touch events |
| collapsable | View | Optimize flat views |
| removeClippedSubviews | FlatList | Off-screen optimization |
| importantForAccessibility | All | Accessibility priority |
// ── StyleSheet (optimized, validated at creation) ──
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e0e0e0',
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#1a1a1a',
},
card: {
marginHorizontal: 16,
marginVertical: 8,
padding: 16,
backgroundColor: '#fff',
borderRadius: 12,
shadowColor: '#000',
shadowOffset: {'{'} width: 0, height: 2 {'}'},
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3, // Android shadow
},
});
// Platform-specific styles
import {'{'} Platform, Dimensions {'}'} from 'react-native';
const {'{'} width, height {'}'} = Dimensions.get('window');
const platformStyles = StyleSheet.create({
container: {
paddingTop: Platform.OS === 'ios' ? 44 : 0,
paddingHorizontal: Platform.select({
ios: 20,
android: 16,
default: 16,
}),
},
responsiveFont: {
fontSize: width < 380 ? 14 : 16,
},
});FlatList over ScrollView + map() for lists with more than ~20 items. FlatList virtualizes rendering — only visible items are mounted. Provide keyExtractor, initialNumToRender, and removeClippedSubviews for optimal performance.import { StyleSheet, Platform } from 'react-native';
// ── Flexbox (default: flexDirection="column") ──
const flexStyles = StyleSheet.create({
row: {
flexDirection: 'row', // horizontal layout
justifyContent: 'center', // main axis: center
alignItems: 'center', // cross axis: center
flexWrap: 'wrap', // wrap items
gap: 12, // spacing between items (RN 0.71+)
},
spaceBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
// flex values
fillSpace: { flex: 1 }, // take all available space
halfWidth: { flex: 1, maxWidth: '50%' },
fixedWidth: { width: 100 },
aspectSquare: { aspectRatio: 1 },
aspectVideo: { aspectRatio: 16 / 9 },
});
// ── Shadows (platform-specific) ──
const shadowStyles = StyleSheet.create({
shadow: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
},
android: {
elevation: 4, // Android only
},
}),
});
// ── Text Styles ──
const textStyles = StyleSheet.create({
heading: {
fontSize: 28,
fontWeight: '700', // '100'-'900', 'bold', 'normal'
color: '#1a1a1a',
letterSpacing: -0.5, // tracking
lineHeight: 34, // leading
},
body: {
fontSize: 16,
fontWeight: '400',
color: '#444',
lineHeight: 24,
},
subtitle: {
fontSize: 14,
color: '#888',
textTransform: 'uppercase',
textAlign: 'center',
textDecorationLine: 'none', // 'underline', 'line-through', 'none'
},
});
// ── Animations with Animated ──
import { Animated, Easing } from 'react-native';
function FadeInView({'{'} children {'}'}) {
const fadeAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
easing: Easing.ease,
useNativeDriver: true, // offload to native thread
}).start();
}, []);
return (
<Animated.View style={{'{'} opacity: fadeAnim {'}'}}>
{'{'}children{'}'}
</Animated.View>
);
}
// ── Reanimated (recommended for complex animations) ──
// npm install react-native-reanimated
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
} from 'react-native-reanimated';
function ReanimatedBox() {
const offset = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
return (
<Animated.View style={[styles.box, animatedStyle]}>
<Text>Animated Box</Text>
</Animated.View>
);
}| Property | React Native | Web CSS |
|---|---|---|
| flexDirection | column | row |
| alignItems | stretch | stretch |
| flexWrap | nowrap | nowrap |
| alignContent | flex-start | stretch |
| Concept | Notes |
|---|---|
| No rem/em | Use dp/px (density-independent) |
| Percentages | Supported: width: "50%" |
| Dimensions.get() | Screen width/height at runtime |
| useWindowDimensions() | Hook for dynamic values |
| PixelRatio | Scale factor for pixel accuracy |
| Platform.select() | Platform-specific values |
| StyleSheet.create() | Validates & optimizes styles |
StyleSheet.create() instead of inline style objects. It validates styles and creates an ID that the native layer can cache. For dynamic styles that change often, inline objects are fine.// ── AsyncStorage (persistent key-value storage) ──
// npm install @react-native-async-storage/async-storage
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEYS = {
AUTH_TOKEN: '@auth_token',
USER_DATA: '@user_data',
ONBOARDING_DONE: '@onboarding_done',
THEME: '@theme_preference',
};
// Store data
async function saveToken(token: string) {
try {
await AsyncStorage.setItem(STORAGE_KEYS.AUTH_TOKEN, token);
} catch (e) {
console.error('Failed to save token', e);
}
}
// Retrieve data
async function getToken(): Promise<string | null> {
try {
return await AsyncStorage.getItem(STORAGE_KEYS.AUTH_TOKEN);
} catch (e) {
return null;
}
}
// Store object (must serialize)
async function saveUser(user: UserData) {
await AsyncStorage.setItem(
STORAGE_KEYS.USER_DATA,
JSON.stringify(user)
);
}
// Retrieve & parse object
async function getUser(): Promise<UserData | null> {
const raw = await AsyncStorage.getItem(STORAGE_KEYS.USER_DATA);
return raw ? JSON.parse(raw) : null;
}
// Remove
async function removeToken() {
await AsyncStorage.removeItem(STORAGE_KEYS.AUTH_TOKEN);
}
// Get all keys
async function getAllKeys() {
return await AsyncStorage.getAllKeys();
}
// Multi-get/multi-set
await AsyncStorage.multiSet([
['key1', 'value1'],
['key2', 'value2'],
]);
const pairs = await AsyncStorage.multiGet(['key1', 'key2']);// ── Zustand (recommended — lightweight, simple) ──
// npm install zustand
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
total: () => number;
}
const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
})),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// Usage in component
function CartScreen() {
const { items, addItem, total } = useCartStore();
// items auto-persist to AsyncStorage!
}// ── Context API (built-in, good for simple global state) ──
import { createContext, useContext, useReducer, ReactNode } from 'react';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
}
type AuthAction =
| { type: 'LOGIN'; payload: { user: User; token: string } }
| { type: 'LOGOUT' }
| { type: 'SET_LOADING'; payload: boolean };
const initialState: AuthState = {
user: null,
token: null,
isLoading: false,
};
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN':
return {
...state,
user: action.payload.user,
token: action.payload.token,
isLoading: false,
};
case 'LOGOUT':
return initialState;
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state;
}
}
const AuthContext = createContext<{
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
} | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
return (
<AuthContext.Provider value={{ state, dispatch }}>
{'{'}children{'}'}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be inside AuthProvider');
return context;
}| Solution | Size | Best For |
|---|---|---|
| useState/useReducer | 0 KB | Local component state |
| Context API | 0 KB | Simple global state |
| Zustand | 1 KB | Most projects (recommended) |
| Jotai | 2 KB | Atomic state |
| Redux Toolkit | 11 KB | Large teams, devtools |
| MobX | 16 KB | Observable patterns |
| Recoil | 22 KB | Facebook apps |
| Library | Features |
|---|---|
| @tanstack/react-query | Caching, refetch, pagination, mutations |
| SWR | Lightweight, revalidation, SSR |
| Axios | Interceptors, cancel, timeout |
| fetch | Built-in, no deps |
| GraphQL Client | Apollo Client, urql |
// ── Camera (Expo) ──
import { CameraView, useCameraPermissions } from 'expo-camera';
function CameraScreen() {
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
if (!permission.granted) {
return (
<View style={styles.container}>
<Text>Camera permission needed</Text>
<Button title="Grant Permission" onPress={requestPermission} />
</View>
);
}
const takePhoto = async () => {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
exif: false,
});
console.log('Photo URI:', photo.uri);
// Upload or save the photo
};
return (
<CameraView ref={cameraRef} style={styles.camera}>
<View style={styles.controls}>
<TouchableOpacity onPress={takePhoto}>
<View style={styles.captureButton} />
</TouchableOpacity>
</View>
</CameraView>
);
}// ── Permissions ──
import * as Location from 'expo-location';
import * as Notifications from 'expo-notifications';
import { launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker';
// Location
async function requestLocation() {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission to access location was denied');
return null;
}
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
return location.coords;
}
// Notifications
async function setupNotifications() {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return;
const token = await Notifications.getExpoPushTokenAsync();
console.log('Push token:', token.data);
// Configure handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
}
// Image Picker
async function pickImage() {
const result = await launchImageLibraryAsync({
mediaTypes: MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled) {
return result.assets[0].uri;
}
return null;
}import { Platform, Linking, Vibration, Haptics } from 'react-native';
// ── Platform Detection ──
const isIOS = Platform.OS === 'ios';
const isAndroid = Platform.OS === 'android';
const version = Platform.Version as number; // iOS: 17.0, Android API: 34
// ── Opening URLs / Deep Links ──
await Linking.openURL('https://example.com');
await Linking.openURL('tel:+1234567890'); // Phone
await Linking.openURL('mailto:test@example.com'); // Email
await Linking.openURL('maps:0,0?q=Paris'); // Maps
// ── Haptic Feedback ──
if (isIOS) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
Haptics.selectionAsync();
}
// ── Vibration (Android) ──
Vibration.vibrate(200); // vibrate 200ms
Vibration.vibrate([100, 50, 100]); // pattern
// ── App State (foreground/background) ──
import { AppState } from 'react-native';
function AppStateExample() {
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
console.log('App came to foreground');
}
appState.current = nextAppState;
}
);
return () => subscription.remove();
}, []);
}| Module | Purpose |
|---|---|
| expo-camera | Camera access & photos |
| expo-image-picker | Gallery/camera image selection |
| expo-location | GPS location |
| expo-notifications | Push notifications |
| expo-auth-session | OAuth (Google, Apple) |
| expo-secure-store | Encrypted key-value storage |
| expo-av | Audio & video playback |
| expo-sensors | Accelerometer, gyroscope |
| expo-clipboard | Copy/paste |
| expo-biometric | Face ID / fingerprint |
| expo-screen-orientation | Lock screen rotation |
| expo-file-system | File read/write |
| Step | Description |
|---|---|
| 1. Turbo Module | New Architecture (C++ bridge) |
| 2. Architecture | JSI for synchronous calls |
| 3. iOS (Swift) | Create .swift + .mm bridge files |
| 4. Android (Kotlin) | Create Module + Package class |
| 5. Codegen | Auto-generates TS types from specs |
| 6. Expo Module | Easier alternative (new ARCH) |
expo-secure-store instead of AsyncStorage for sensitive data like tokens and passwords. It encrypts data using the device keystore (Keychain on iOS, EncryptedSharedPreferences on Android).import React, { memo, useCallback, useMemo, useRef } from 'react';
// ── 1. React.memo (prevent unnecessary re-renders) ──
const UserCard = memo(function UserCard({ user, onPress }) {
return (
<TouchableOpacity onPress={onPress}>
<Text>{'{'}user.name{'}'}</Text>
</TouchableOpacity>
);
}, (prev, next) => {
// Custom comparison — return true if props are equal (skip re-render)
return prev.user.id === next.user.id;
});
// ── 2. useMemo / useCallback ──
function ExpensiveList({ items, filter }) {
const filteredItems = useMemo(
() => items.filter(item => item.category === filter),
[items, filter]
);
const handlePress = useCallback((id: string) => {
console.log('Pressed:', id);
}, []);
return (
<FlatList
data={filteredItems}
renderItem={({'{'} item {'}'}) => (
<UserCard user={item} onPress={() => handlePress(item.id)} />
)}
keyExtractor={(item) => item.id}
/>
);
}
// ── 3. FastImage (cached images) ──
// npm install react-native-fast-image
import FastImage from 'react-native-fast-image';
function OptimizedImage({'{'} uri, style {'}'}) {
return (
<FastImage
source={{ uri, priority: FastImage.priority.normal }}
style={style}
resizeMode={FastImage.resizeMode.cover}
defaultSource={require('./placeholder.png')}
/>
);
}
// ── 4. useCallback with FlatList ──
function OptimizedFlatList() {
const renderItem = useCallback(({'{'} item {'}'}) => (
<MemoizedCard item={item} />
), []);
const keyExtractor = useCallback((item) => item.id, []);
const ListHeaderComponent = useMemo(
() => <Text style={styles.header}>Items</Text>,
[]
);
return (
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={ListHeaderComponent}
removeClippedSubviews={true}
maxToRenderPerBatch={5}
windowSize={5}
initialNumToRender={5}
/>
);
}| Technique | Impact |
|---|---|
| FlatList (not ScrollView) | Critical for long lists |
| React.memo on list items | Reduces re-renders |
| useCallback for handlers | Stable function refs |
| useMemo for computed values | Avoid re-computation |
| FastImage for remote images | Caching & memory mgmt |
| useNativeDriver: true | Offload animations to native |
| BlurView for overlays | GPU-accelerated blur |
| Hermes engine | Smaller JS bundle, faster startup |
| Flipper debugging | Network, layout, DB inspection |
| Bundle analysis | Identify large dependencies |
| Feature | Description |
|---|---|
| Pre-compiled bytecode | Faster startup |
| Smaller APK/IPA | No full JS engine shipped |
| Lazy compilation | Functions compile on first call |
| Enabled by default | Since RN 0.70 |
| Hermes bytecode | .hbc format instead of raw JS |
| Source maps | Supported via Hermes CLI |
useNativeDriver: true on ALL animations to avoid the JS bridge bottleneck.// ── Axios setup with interceptors ──
import axios, { AxiosInstance, AxiosError } from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
const apiClient: AxiosInstance = axios.create({
baseURL: 'https://api.example.com/v1',
timeout: 15000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
// Request interceptor (add auth token)
apiClient.interceptors.request.use(
async (config) => {
const token = await AsyncStorage.getItem('@auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor (handle 401)
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
// Token expired — redirect to login
await AsyncStorage.removeItem('@auth_token');
// navigation.reset to login
}
return Promise.reject(error);
}
);
// ── React Query integration ──
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const { data } = await apiClient.get('/users');
return data;
},
staleTime: 5 * 60 * 1000, // 5 min
gcTime: 10 * 60 * 1000, // cache for 10 min (formerly cacheTime)
});
}
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateUserDTO) =>
apiClient.patch('/users/' + data.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// ── Offline-first with React Query ──
function useOfflineUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => apiClient.get('/users').then(r => r.data),
networkMode: 'offlineFirst', // show cache while fetching
placeholderData: (prev) => prev, // keep previous data
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
});
}networkMode: 'offlineFirst' in React Query to show cached data immediately while fetching fresh data in the background. This dramatically improves perceived performance on mobile networks.