Here's the problem you'll hit. A user fills out onboarding, sets dark mode, adds three entries, then force-quits the app. They reopen it and everything's gone. Or the softer version: the app launches in light mode for half a second, flashes white, then snaps to the dark theme they actually chose. Both are the same bug wearing different clothes. Something lived in the wrong tier of memory.
Your app has three kinds of memory
A React Native app stores data in three local tiers, and each one survives a different amount of destruction. Above them sits the server, which is the only thing that survives a reinstall.
- In-memory state (
useState,useReducer, Zustand held in RAM). Fast, but it survives nothing. A reload, a background-kill, or a crash wipes it. - Key-value storage (MMKV, AsyncStorage, expo-secure-store). Small values that survive a restart. Themes, flags, tokens.
- Structured storage (expo-sqlite, WatermelonDB). Rows you can query, sort, and read offline. Notes, entries, a synced list.
The server (Supabase or your own API) is the source of truth. Every local tier is a cache of it.

Match each value to the right tier
The right tier for a value comes down to two questions: how much destruction it must survive, and how you'll read it back. A single flag reads differently than a list you sort.
| What you are storing | Tier | Store to use |
|---|---|---|
| Auth token, API keys | secrets | expo-secure-store |
| Theme, onboarding-done, feature flags | small key-value | MMKV / AsyncStorage |
| Notes, entries, a list you query | structured records | expo-sqlite |
| The canonical copy shared across devices | source of truth | your server, cached locally |
One security note that catches people. AsyncStorage and MMKV aren't encrypted by default, so an auth token doesn't belong there. Secrets go in expo-secure-store, which is backed by the iOS Keychain and Android Keystore.
Why your data disappears: AsyncStorage is async
The most common "data loss" bug isn't lost data at all. It's reading storage a beat after your UI has already rendered with a default value.
AsyncStorage returns a promise. Your component renders once with its initial state, then the saved value arrives and triggers a second render. The user sees the gap.
function useThemePref() {
const [theme, setTheme] = useState("light"); // default paints first
useEffect(() => {
AsyncStorage.getItem("theme").then((saved) => {
if (saved) setTheme(saved); // real value lands after first paint
});
}, []);
return theme;
}
The user picked dark mode last week. On launch they still see a white flash, then it corrects. The data was never lost. You just read it too late.
Make key-value memory synchronous with MMKV
MMKV reads and writes synchronously, so the value is already there on the very first render. No promise, no await, no flash. Pair it with Zustand's persist middleware and your store rehydrates itself.
import { MMKV } from "react-native-mmkv";
import { create } from "zustand";
import { persist, createJSONStorage, StateStorage } from "zustand/middleware";
const storage = new MMKV();
const mmkvStorage: StateStorage = {
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
};
export const usePrefs = create(
persist(
(set) => ({ theme: "light", setTheme: (theme) => set({ theme }) }),
{ name: "prefs", storage: createJSONStorage(() => mmkvStorage) }
)
);
Because the read is synchronous, usePrefs.getState().theme is correct on the first paint. The white flash from the AsyncStorage version is gone, and you didn't change a single component.
Gate the first render on hydration
When a tier is genuinely async (SQLite, a network cache), the cleanest fix is to not render the app until its memory has loaded. Hold a spinner until the first read resolves, then mount the tree with real data.
import { useEffect, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import { useSQLiteContext } from "expo-sqlite";
function Root() {
const db = useSQLiteContext();
const [prefs, setPrefs] = useState<Prefs | null>(null);
useEffect(() => {
db.getAllAsync("select key, value from settings")
.then((rows) => setPrefs(rowsToObject(rows))); // memory is now warm
}, [db]);
if (!prefs) {
return (
<View style={{ flex: 1, justifyContent: "center" }}>
<ActivityIndicator />
</View>
);
}
return <App prefs={prefs} />;
}
This is exactly what a settings provider backed by expo-sqlite does in our own apps. It shows a spinner while the first query runs, seeds its context with the saved rows, and only then renders the screens. The user never sees a default value flicker into the real one, because the real one was loaded before anything painted.
The takeaway
Storing data in React Native is a routing problem, not a library problem. You're not choosing between AsyncStorage and SQLite in the abstract. You're asking, for one specific value, how much it must survive and how you'll read it back. Answer those two questions and the tier picks itself.

Three rules carry most of the weight. Put each value in the lowest tier that meets its durability and access needs. Never let an async read paint a default first, either make the read synchronous or gate the render on it. And keep the server as the source of truth, with every local tier treated as a cache you can rebuild. Do that and your app remembers exactly what the user expects, every time they come back.
I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].
Get the next one in your inbox → subscribe at astraedus.dev.