Install: it is not in React Native anymore
Half the tutorials still on the internet open with import { AsyncStorage } from 'react-native'. That export was deprecated and then removed. The maintained implementation is a community package:
npx expo install @react-native-async-storage/async-storage # bare React Native: npm install @react-native-async-storage/async-storage && npx pod-install
Use npx expo install rather than npm install in an Expo project — it resolves the version that matches your SDK instead of the newest one on npm, which is the single most common cause of a native module that builds fine and crashes on launch.
Wrap it once, in a typed module
Every screen calling the raw API means JSON.parse scattered through your codebase and a key typo waiting to become a silent data-loss bug. One module fixes both:
// lib/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
export const KEYS = {
settings: 'settings.v1',
draft: 'compose.draft.v1',
lastSync: 'sync.lastAt.v1',
} as const;
export async function load<T>(key: string, fallback: T): Promise<T> {
try {
const raw = await AsyncStorage.getItem(key);
return raw == null ? fallback : (JSON.parse(raw) as T);
} catch {
// Corrupt value or a schema change. Do not crash the app over a cache.
await AsyncStorage.removeItem(key);
return fallback;
}
}
export async function save(key: string, value: unknown) {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch (e) {
// Android throws here when the 6 MB database is full.
console.warn('storage write failed', key, e);
}
}Two details in there earn their keep:
- Versioned key names.
settings.v1costs nothing today and saves you a migration script the first time the shape of that object changes. Bump the suffix, and old installs fall through to the fallback instead of parsing a stale shape. - A catch that deletes. A half-written or schema-drifted value should degrade to the default, not throw inside a render. Persistence is a cache; treat a bad read as a miss.
The hydration flash, and how to stop it
This is the number-one AsyncStorage bug and it is a rendering problem, not a storage one. Your state starts empty, React renders, then the promise resolves. Users see the login screen for 200 ms on every cold start, or the app flashes light before your saved dark theme lands.
// providers/SettingsProvider.tsx
const [settings, setSettings] = useState(DEFAULTS);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
let alive = true;
load(KEYS.settings, DEFAULTS).then((s) => {
if (!alive) return;
setSettings(s);
setHydrated(true); // flip AFTER the state is applied
});
return () => { alive = false; };
}, []);
// Keep the native splash up instead of rendering a wrong first frame:
if (!hydrated) return null;
return <SettingsContext.Provider value={{ settings, setSettings }}>{children}</SettingsContext.Provider>;Returning null only works if something else is on screen — which is exactly what holding the splash screen with preventAutoHideAsync is for. Hide it in the same place you flip hydrated, and the user never sees a frame of the wrong state.
Batch writes — do not await in a loop
Each setItem is a round-trip across the bridge to native storage. Twelve of them in sequence on a settings save is twelve round-trips, and on a slow Android device that is a visible stall.
// Slow: one round-trip per key for (const [k, v] of entries) await AsyncStorage.setItem(k, JSON.stringify(v)); // Fast: one batched native call await AsyncStorage.multiSet(entries.map(([k, v]) => [k, JSON.stringify(v)])); // Same on the way in const pairs = await AsyncStorage.multiGet([KEYS.settings, KEYS.draft, KEYS.lastSync]);
One more pattern worth knowing: for a value that changes on every keystroke — a draft, a search box, a scroll position — debounce the write by 300–500 ms rather than persisting each character. And avoid AsyncStorage.clear()as a logout implementation; it wipes every library’s keys too, including your analytics install ID and any persisted query cache. Remove your own keys explicitly.
The 6 MB wall on Android
On Android, AsyncStorage is backed by a SQLite database with a default ceiling of 6 MB. Cross it and writes start failing — usually not on your phone, and usually months after launch when a cached list has grown. The ceiling is raiseable in a bare or prebuilt project:
# android/gradle.properties AsyncStorage_db_size_in_MB=20
That requires a native rebuild, so it does not apply in Expo Go — and if you need it at all, that is a strong signal you are storing the wrong shape of data. A cache that grows without bound wants an eviction policy or a real database, not a bigger number in a properties file. iOS has no comparable documented cap, which is precisely why this bug is always Android-only and always a surprise.
Where each kind of data actually belongs
| Data | Home | Why |
|---|---|---|
| Theme, units, onboarding seen | AsyncStorage | Tiny, read once at launch, no privacy weight |
| Auth / refresh token | expo-secure-store | Needs OS-level encryption at rest |
| Cached API responses | AsyncStorage or TanStack Query persister | Fine until the payloads get big |
| Offline records the user edits | expo-sqlite | Needs queries, indexes, and partial writes |
| Values read on every render | react-native-mmkv | Synchronous, no await in render |
| Photos, video, PDFs | expo-file-system | Blobs do not belong in a KV store |
The honest summary: AsyncStorage is correct for small, non-sensitive values read a handful of times per session. That covers more of a real app than people expect — and the moment it does not, the replacement is usually SQLite for shape reasons or SecureStore for privacy reasons, not MMKV for speed reasons.
Persisting a whole store
If you already use Zustand, do not hand-roll the effect — the persist middleware handles hydration, versioning, and partial persistence, and it exposes the hydration flag you need for the section above:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const useSettings = create(
persist(
(set) => ({ theme: 'system', units: 'metric', setTheme: (theme) => set({ theme }) }),
{
name: 'settings.v1',
storage: createJSONStorage(() => AsyncStorage),
partialize: (s) => ({ theme: s.theme, units: s.units }), // never persist functions or transient UI
version: 1,
migrate: (persisted, from) => (from === 0 ? { ...persisted, units: 'metric' } : persisted),
},
),
);
// Gate your first render on this:
const hydrated = useSettings.persist.hasHydrated();partialize is the field people skip and regret: without it, transient UI state and loading flags get written to disk and restored on next launch, which is how an app comes back with a spinner that never resolves.
Skip the boilerplate
Storage wiring, hydration gating, and a splash that waits for it are the same twenty minutes in every project. Describe your app at shipnative.dev and it generates a React Native app with persistence already set up correctly — running on your phone in minutes, with the full Expo project available to export.