React Native – Storing Data on the Device

July 25, 20264 min readUpdated 8/24/2026

There is no localStorage. That turns out to be an improvement, because it forces a decision the web lets you skip: how secret is this?

Two stores, two purposes

AsyncStorage is the direct equivalent of localStorage — a plain key/value store, unencrypted, readable by anyone with access to the device's filesystem or a backup of it. It is for things that are inconvenient to lose and harmless to leak.

expo-secure-store writes to the iOS Keychain and Android's EncryptedSharedPreferences, both backed by the OS keystore. Another app cannot read it. It is for anything an attacker could use.

The web app this one mirrors keeps its JWT in localStorage, with a comment apologising that a single XSS bug leaks it. On a phone there is a better answer, so the token goes in the keychain:

export const secureStorage = {
  async get(key: string): Promise<string | null> {
    if (isWeb) return AsyncStorage.getItem(key);
    return SecureStore.getItemAsync(key);
  },

  async set(key: string, value: string): Promise<void> {
    if (isWeb) return AsyncStorage.setItem(key, value);
    return SecureStore.setItemAsync(key, value);
  },

The web branch exists because expo-secure-store has no web implementation and throws if called there — and the app runs on web as a development preview. It degrades to AsyncStorage, which on web is localStorage: exactly the web app's trade-off, made explicitly rather than by accident.

Limits worth knowing

Secure storage has a size limit — around 2 KB on Android. Fine for a token, wrong for anything bulky. And on iOS, keychain items can outlive an uninstall unless you say otherwise, which occasionally surprises people testing a "fresh install".

Everything is asynchronous, and that changes the app

localStorage.getItem returns a string. Both of these return a Promise. That single difference shapes the whole startup sequence.

On the web, the first render already knows whether anyone is signed in. Here it does not — for a few frames the app genuinely cannot tell. Render the signed-out UI during that gap and the app flashes "Sign in" and then swaps; worse, a route guard bounces a signed-in user to the login screen.

So an auth provider needs a third state — not "signed in" or "signed out" but "still finding out" — and the splash screen stays up until it resolves. That is what SplashScreen.preventAutoHideAsync() is for, and it has to be called at module level: by the time a component mounts it is too late to prevent the first frame.

Name the keys once

export const tokenStore = {
  get: () => secureStorage.get(StorageKey.AUTH_TOKEN),
  set: (token: string) => secureStorage.set(StorageKey.AUTH_TOKEN, token),
  clear: () => secureStorage.remove(StorageKey.AUTH_TOKEN),
};

A typo in a storage key fails silently — the read returns null and the app behaves as if the user had never signed in. Declaring the keys in one object removes that whole class of bug, and it also makes it obvious at a glance what the app leaves on the device.

Wrapping each key in a small named store is worth the four lines. Screens never see a key at all, and swapping the implementation later touches one file.

Storage can fail

export const deviceStorage = {
  async get(key: string): Promise<string | null> {
    try {
      return await AsyncStorage.getItem(key);
    } catch {
      return null;
    }
  },

On a device this is not theoretical: no space left, a corrupted store, a permissions oddity. A cart id that cannot be read should mean "start with an empty cart", not an unhandled rejection that takes down a screen. Decide per store whether a failure is fatal — for a token it might be, for a convenience value it is not.

What belongs on the device at all

Less than you would think. The demo app stores exactly two things: the JWT, and a UUID naming which server-side cart belongs to this device.

Note what it does not store — the cart's contents. Those live in the database, which is the point: force-quitting the app, or opening it a week later, recovers the same basket, re-priced against today's menu. Storing the items locally would mean a cart that quietly honours last month's prices.

That is a useful general test. If the data is authoritative, it belongs on the server and the device holds a pointer. If it is a preference, the device is fine.

The other options

MMKV is a synchronous, much faster key/value store. Synchronous is the interesting part — it removes the "still finding out" state entirely. It is a native module, so it needs a development build.

SQLite (expo-sqlite) when you have real relational data or want offline-first with queries. WatermelonDB or Realm when you need sync as well.

expo-file-system for actual files — images, downloads, caches. Do not base64 a photo into AsyncStorage; it will be slow and it will hit limits.

What is next

Platform APIs and Device Differences — including the one that can end your process without asking.