Local storage options
AsyncStorage — the default, slow choice
AsyncStorage is RN’s built-in key-value store. It’s simple but has real downsides:
- Asynchronous — every read is async, adding latency to startup
- String-only values — you must
JSON.stringify/parseeverything - Single-threaded JS reads — not offloaded, competes with your JS thread
- No encryption — not suitable for sensitive data
import AsyncStorage from '@react-native-async-storage/async-storage';
// Write
await AsyncStorage.setItem('user', JSON.stringify(user));
// Read
const raw = await AsyncStorage.getItem('user');
const user = raw ? JSON.parse(raw) : null;
// Multi-get (batched)
const [[, token], [, user]] = await AsyncStorage.multiGet(['token', 'user']);
MMKV — the fast alternative
react-native-mmkv is a C++ key-value store (WeChat’s production storage). Key advantages:
- ~30x faster than AsyncStorage — reads are synchronous (native memory-mapped file)
- Synchronous reads — no
await, no latency at startup - Supports multiple types — strings, numbers, booleans, bytes natively
- Optional AES encryption
- Supports multiple instances (isolated stores per domain)
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
// Sync reads — no await
storage.set('user.token', 'abc123');
const token = storage.getString('user.token'); // synchronous!
storage.set('app.darkMode', true);
const isDark = storage.getBoolean('app.darkMode'); // synchronous
// With encryption
const secureStorage = new MMKV({ id: 'secure', encryptionKey: 'my-key' });
Use MMKV for: auth tokens, user preferences, app settings, anything you read at startup. Use AsyncStorage only if you can’t add native dependencies (Expo Go, CI constraints).
SQLite / WatermelonDB — for structured relational data
For complex queries, relationships, or large datasets:
// WatermelonDB — observable reactive database
const postsCollection = database.get('posts');
const activePosts = await postsCollection.query(
Q.where('is_active', true),
Q.sortBy('created_at', Q.desc)
).fetch();
Rule of thumb: MMKV for simple key-value, SQLite/WatermelonDB for relational data with queries.
Server state with TanStack Query (React Query)
“Server state” is data that lives on a server, has a cache TTL, and needs to stay in sync. useState is wrong for this — it doesn’t handle loading, errors, refetching, or cache invalidation.
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // data is "fresh" for 5 minutes
gcTime: 10 * 60 * 1000, // keep in cache for 10 minutes after unmount
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // exponential backoff
},
},
});
// Querying
function PostList({ userId }: { userId: string }) {
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: ['posts', userId], // cache key — array uniquely identifies this query
queryFn: () => api.getPosts(userId),
enabled: !!userId, // don't run until userId exists
});
if (isPending) return <ActivityIndicator />;
if (isError) return <ErrorView message={error.message} onRetry={refetch} />;
return <FlatList data={data} renderItem={renderItem} />;
}
Mutations with optimistic updates
Optimistic updates show the result immediately before the server confirms — makes the UI feel instant:
function useAddPost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newPost: NewPost) => api.createPost(newPost),
onMutate: async (newPost) => {
// Cancel any outgoing refetches (to avoid overwriting optimistic update)
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot the previous value
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically update the cache
queryClient.setQueryData(['posts'], (old: Post[]) => [
{ ...newPost, id: 'temp-' + Date.now() }, // temporary id
...old,
]);
return { previousPosts }; // context for rollback
},
onError: (err, newPost, context) => {
// Roll back on failure
queryClient.setQueryData(['posts'], context?.previousPosts);
showToast('Failed to create post');
},
onSettled: () => {
// Always refetch to sync with server truth
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
Infinite queries
function useInfinitePosts(userId: string) {
return useInfiniteQuery({
queryKey: ['posts', userId, 'infinite'],
queryFn: ({ pageParam = 1 }) => api.getPosts(userId, { page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
});
}
function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfinitePosts(userId);
const posts = data?.pages.flatMap(page => page.items) ?? [];
return (
<FlatList
data={posts}
renderItem={renderItem}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.3}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
);
}
Offline detection
import NetInfo from '@react-native-community/netinfo';
// One-time check
const state = await NetInfo.fetch();
console.log('Connected:', state.isConnected);
console.log('Type:', state.type); // 'wifi', 'cellular', 'none'
// Subscribe to changes
const unsubscribe = NetInfo.addEventListener(state => {
if (!state.isConnected) showOfflineBanner();
else hideOfflineBanner();
});
Offline-first patterns
For apps that must work offline:
- Cache-first: serve from cache immediately, update in background (React Query’s
staleTime) - Offline queue: queue mutations when offline, replay when reconnected
- Optimistic UI: assume success, roll back on confirmed failure
// Simple offline queue with MMKV
const queue = new MMKV({ id: 'offline-queue' });
async function mutateWithQueue(payload: any) {
const state = await NetInfo.fetch();
if (!state.isConnected) {
const existing = JSON.parse(queue.getString('queue') ?? '[]');
queue.set('queue', JSON.stringify([...existing, payload]));
return; // will be replayed when online
}
return api.create(payload);
}
// On reconnect — replay queued mutations
NetInfo.addEventListener(async (state) => {
if (state.isConnected) {
const pending = JSON.parse(queue.getString('queue') ?? '[]');
for (const item of pending) await api.create(item);
queue.set('queue', '[]');
}
});
useMutation with onMutate/onError implements optimistic updates with rollback. For offline-first, I combine React Query’s staleTime for cache-first reads with a MMKV-backed mutation queue that replays on reconnect.”