Offline-First Basics
10 examples to get you started with offline-first mobile data - 7 basic and 3 intermediate. Mobile users lose signal constantly; treat connectivity as a feature state, not an exception.
Search across all documentation pages
10 examples to get you started with offline-first mobile data - 7 basic and 3 intermediate. Mobile users lose signal constantly; treat connectivity as a feature state, not an exception.
Scaffold a production-shaped Expo app with an explicit SDK 57 pin. Offline examples assume Expo Router, TypeScript, and TanStack Query as the server-state layer.
npx create-expo-app@latest MyApp --template default@sdk-57
cd MyApp
npm installConfirm the SDK pin and install connectivity + server-state dependencies:
{
"dependencies": {
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
}
}npx expo install @tanstack/react-query @react-native-community/netinfoTooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
Offline-first starts in product and architecture - not in a NetInfo hook added after launch. Document the assumption in your data layer README or ADR.
Offline-first assumptions (team contract):
1. Reads succeed from local cache even when isConnected === false
2. Writes are accepted locally immediately; server confirmation is async
3. Conflicts are resolved by a documented strategy (server wins, LWW, or CRDT)
4. Users always know whether data is live, stale, or pending sync
5. Cold start must render last-known-good within one frame budget where possible// src/data/offlinePolicy.ts
export const offlinePolicy = {
maxStaleAgeMs: 24 * 60 * 60 * 1000,
showOfflineBanner: true,
allowOptimisticWrites: true,
conflictStrategy: "server-authoritative" as const,
};fetch when cached data existsconflictStrategy must match backend capabilities - do not promise CRDTs without server supportRelated: Sync Strategies - LWW, CRDT overview, server reconciliation
Users need predictable copy for four states. Design and engineering should agree on labels before implementation.
| State | User sees | Engineering signal |
|---|---|---|
| Live | Normal UI, optional subtle "Updated just now" | isFetching === false, online, fresh cache |
| Stale | Data visible + "Saved data from 2:14 PM" | dataUpdatedAt older than staleTime or offline read |
| Syncing | Same data + "Updating…" indicator | isFetching === true with existing data |
| Unavailable | Empty or skeleton + retry | No cache and fetch failed, or auth expired |
// src/components/DataFreshnessLabel.tsx
import { Text, StyleSheet } from "react-native";
type Props = {
dataUpdatedAt: number;
isFetching: boolean;
isOffline: boolean;
};
export function DataFreshnessLabel({ dataUpdatedAt, isFetching, isOffline }: Props) {
if (isFetching) {
return <Text style={styles.muted}>Updating…</Text>;
}
const label = isOffline ? "Offline - showing saved data from" : "Updated";
return (
<Text style={styles.muted}>
{label} {new Date(dataUpdatedAt).toLocaleTimeString()}
</Text>
);
}
const styles = StyleSheet.create({
muted: { fontSize: 12, color: "#6b7280", marginBottom: 8 },
});Related: ../error-resilience/network-failure-ux/network-failure-ux.md - offline banners and retry queues
Use @react-native-community/netinfo for ongoing reachability - not a one-time fetch probe on mount.
npx expo install @react-native-community/netinfo// src/hooks/useConnectivity.ts
import { useNetInfo } from "@react-native-community/netinfo";
export function useConnectivity() {
const net = useNetInfo();
const isOffline =
net.isConnected === false || net.isInternetReachable === false;
const isReachabilityUnknown =
net.isConnected == null || net.isInternetReachable == null;
return { net, isOffline, isReachabilityUnknown };
}// src/components/OfflineBanner.tsx
import { StyleSheet, Text, View } from "react-native";
import { useConnectivity } from "@/hooks/useConnectivity";
export function OfflineBanner() {
const { isOffline, isReachabilityUnknown } = useConnectivity();
if (isReachabilityUnknown || !isOffline) return null;
return (
<View style={styles.banner} accessibilityRole="alert">
<Text style={styles.text}>You are offline. Changes will sync when connected.</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: { backgroundColor: "#fef3c7", padding: 10 },
text: { color: "#92400e", textAlign: "center" },
});isInternetReachable === null on cold start is unknown - do not show the offline banner yetisConnected === false is stronger than a failed HTTP call - trust NetInfo for connectivity UXRelated: ../state-management/tanstack-query/tanstack-query.md -
onlineManagerwiring
Pause query retries when the device is offline - uncapped retries on airplane mode drain battery and spam logs.
// src/lib/setupMobileQuery.ts
import NetInfo from "@react-native-community/netinfo";
import { AppState, type AppStateStatus } from "react-native";
import { focusManager, onlineManager } from "@tanstack/react-query";
export function setupMobileQuery() {
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => {
const online =
!!state.isConnected && state.isInternetReachable !== false;
setOnline(online);
})
);
const onChange = (status: AppStateStatus) => {
focusManager.setFocused(status === "active");
};
const sub = AppState.addEventListener("change", onChange);
return () => sub.remove();
}// app/_layout.tsx (excerpt)
import { QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createQueryClient } from "@/lib/queryClient";
import { setupMobileQuery } from "@/lib/setupMobileQuery";
export default function RootLayout() {
const [queryClient] = useState(() => createQueryClient());
useEffect(() => setupMobileQuery(), []);
return (
<QueryClientProvider client={queryClient}>
{/* routes */}
</QueryClientProvider>
);
}onlineManager controls whether Query considers the client online for retries and refetchOnReconnectfocusManager ties refetchOnWindowFocus to AppState - refetch when the app returns to foregroundsetupMobileQuery() once at bootstrap - duplicate listeners cause flip-flopping online stateRelated: ../state-management/tanstack-query/tanstack-query.md - cache policies on mobile
Configure staleTime so tabs and back navigation do not flash empty spinners when cached data exists.
// src/lib/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 30 * 60_000,
retry: 2,
refetchOnReconnect: true,
},
},
});
}// app/(tabs)/jobs/index.tsx
import { useQuery } from "@tanstack/react-query";
import { FlatList, Text, View } from "react-native";
import { DataFreshnessLabel } from "@/components/DataFreshnessLabel";
import { OfflineBanner } from "@/components/OfflineBanner";
import { useConnectivity } from "@/hooks/useConnectivity";
import { fetchJobs } from "@/api/jobs";
export default function JobsScreen() {
const { isOffline } = useConnectivity();
const { data, isFetching, isPending, isError, dataUpdatedAt, refetch } = useQuery({
queryKey: ["jobs"],
queryFn: fetchJobs,
});
return (
<View style={{ flex: 1 }}>
<OfflineBanner />
{data && (
<DataFreshnessLabel
dataUpdatedAt={dataUpdatedAt}
isFetching={isFetching}
isOffline={isOffline}
/>
)}
{isPending && !data && <Text>Loading jobs…</Text>}
{isError && !data && <Text onPress={() => refetch()}>Could not load. Tap to retry.</Text>}
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
</View>
);
}isPending && !data is the only case for a full-screen loader - if data exists, SWR appliesstaleTime: 60_000 keeps the jobs list fresh for one minute without refetch on every focusRelated: AsyncStorage Patterns - persist Query cache for cold start
A 500 from a healthy network is not the same as airplane mode - use different headlines and recovery paths.
// src/lib/classifyFetchError.ts
import type { NetInfoState } from "@react-native-community/netinfo";
export type FailureKind = "offline" | "server" | "auth" | "unknown";
export function classifyFailure(error: unknown, net: NetInfoState): FailureKind {
const offline =
net.isConnected === false || net.isInternetReachable === false;
if (offline) return "offline";
if (error instanceof Response) {
if (error.status === 401 || error.status === 403) return "auth";
if (error.status >= 500) return "server";
}
if (error instanceof Error && error.message.includes("Network request failed")) {
return "offline";
}
return "unknown";
}const copy: Record<FailureKind, { title: string; action: string }> = {
offline: { title: "You are offline", action: "Showing saved data" },
server: { title: "Service temporarily unavailable", action: "Try again" },
auth: { title: "Session expired", action: "Sign in again" },
unknown: { title: "Something went wrong", action: "Try again" },
};queryClient.removeQueries() on logoutRelated: ../error-resilience/user-facing-error-copy/user-facing-error-copy.md - category-specific copy
When offline, the app should still accept user intent - store locally and surface sync status.
// src/data/outbox.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
export type OutboxItem = {
id: string;
type: "create_note";
payload: { text: string };
createdAt: number;
idempotencyKey: string;
};
const OUTBOX_KEY = "sync:outbox:v1";
export async function enqueueOutbox(item: OutboxItem) {
const raw = await AsyncStorage.getItem(OUTBOX_KEY);
const queue: OutboxItem[] = raw ? JSON.parse(raw) : [];
queue.push(item);
await AsyncStorage.setItem(OUTBOX_KEY, JSON.stringify(queue));
}
export async function readOutbox(): Promise<OutboxItem[]> {
const raw = await AsyncStorage.getItem(OUTBOX_KEY);
return raw ? JSON.parse(raw) : [];
}// UI indicator on a note composer
{pendingSync && (
<Text style={{ color: "#6b7280" }}>Saved on device - will sync when online</Text>
)}idempotencyKey - replays after reconnect must not duplicate server rowssync:outbox:v1) - migrations follow AsyncStorage PatternsNetInfo reconnect and on app foreground - see Background Sync & TaskManagerRelated: Optimistic UI - instant UI with rollback on failure
Hydrate TanStack Query from AsyncStorage so catalog screens render immediately after process kill.
npx expo install @react-native-async-storage/async-storage @tanstack/react-query-persist-client @tanstack/query-async-storage-persister// src/lib/queryPersister.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
export const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: "REACT_QUERY_OFFLINE_CACHE",
throttleTime: 1000,
});// app/_layout.tsx (excerpt)
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { asyncStoragePersister } from "@/lib/queryPersister";
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: asyncStoragePersister,
maxAge: 1000 * 60 * 60 * 24,
dehydrateOptions: {
shouldDehydrateQuery: (query) => query.queryKey[0] !== "session",
},
}}
>
{children}
</PersistQueryClientProvider>maxAge caps how old persisted cache can be - stale financial data may need shorter TTLqueryClient.clear() and wipe the persister keyRelated: expo-sqlite - SQLite-backed persister for large caches
Field apps sometimes need a forced offline mode (tunnel, airplane hangar) independent of NetInfo.
// src/stores/offlineModeStore.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
type OfflineModeStore = {
forcedOffline: boolean;
setForcedOffline: (value: boolean) => void;
};
export const useOfflineModeStore = create<OfflineModeStore>()(
persist(
(set) => ({
forcedOffline: false,
setForcedOffline: (forcedOffline) => set({ forcedOffline }),
}),
{ name: "offline-mode", storage: createJSONStorage(() => AsyncStorage) }
)
);// src/hooks/useEffectiveConnectivity.ts
import { useNetInfo } from "@react-native-community/netinfo";
import { useOfflineModeStore } from "@/stores/offlineModeStore";
export function useEffectiveConnectivity() {
const net = useNetInfo();
const forcedOffline = useOfflineModeStore((s) => s.forcedOffline);
const netOffline =
net.isConnected === false || net.isInternetReachable === false;
return {
isOffline: forcedOffline || netOffline,
forcedOffline,
netOffline,
};
}onlineManager.setOnline(false) when forced - Query must not refetch in hangar modeRelated: MMKV & High-Performance KV - faster reads for mode flags
Tie connectivity, cache, storage, and UX into one checklist screen teams can copy.
// src/features/checklist/OfflineChecklistScreen.tsx
import { useQuery } from "@tanstack/react-query";
import { Switch, Text, View } from "react-native";
import { DataFreshnessLabel } from "@/components/DataFreshnessLabel";
import { OfflineBanner } from "@/components/OfflineBanner";
import { useEffectiveConnectivity } from "@/hooks/useEffectiveConnectivity";
import { useOfflineModeStore } from "@/stores/offlineModeStore";
import { fetchChecklist } from "@/api/checklist";
export function OfflineChecklistScreen() {
const { isOffline, forcedOffline } = useEffectiveConnectivity();
const setForcedOffline = useOfflineModeStore((s) => s.setForcedOffline);
const { data, isFetching, isPending, dataUpdatedAt, refetch } = useQuery({
queryKey: ["checklist"],
queryFn: fetchChecklist,
staleTime: 5 * 60_000,
// Skip network refetch in forced-offline mode; cached data still renders via SWR
enabled: !forcedOffline,
});
return (
<View style={{ flex: 1, padding: 16 }}>
<OfflineBanner />
<View style={{ flexDirection: "row", alignItems: "center", marginBottom: 12 }}>
<Text style={{ flex: 1 }}>Force offline mode</Text>
<Switch value={forcedOffline} onValueChange={setForcedOffline} />
</View>
{data && (
<DataFreshnessLabel
dataUpdatedAt={dataUpdatedAt}
isFetching={isFetching}
isOffline={isOffline}
/>
)}
{isPending && !data && <Text>Loading checklist…</Text>}
{!data && isOffline && (
<Text onPress={() => refetch()}>No saved checklist. Connect to download.</Text>
)}
{data?.map((item) => (
<Text key={item.id}>• {item.label}</Text>
))}
</View>
);
}Offline read path checklist:
✓ NetInfo + onlineManager wired at bootstrap
✓ staleTime configured per screen sensitivity
✓ Cached data shown with freshness label
✓ Offline vs server errors classified
✓ Writes enqueue locally with idempotency keys
✓ Persisted Query cache for cold start (optional)
✓ Forced offline mode for field testing (optional)fetchChecklist with your domain APIRelated: Data Layer Best Practices - section summary | Sync Strategies - reconciliation rules
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026