AsyncStorage

A string-only key-value store for small preferences, asynchronous by design, with nothing in it encrypted.

SyntaxAsyncStorage.setItem(key: string, value: string): Promise<void>

AsyncStorage is a small key-value store that survives app restarts. Keys are strings, values are strings, and every method returns a promise. It is not part of React Native core, so install the community package: npx expo install @react-native-async-storage/async-storage.

It is not localStorage

The method names match the browser's localStorage, but there is no synchronous version and no window object to reach it through. Every read is awaited, so your first render happens before the stored value arrives. Either keep a loading flag, or accept one frame showing the default.

Everything is a string

setItem takes a string only. Objects go out through JSON.stringify and come back through JSON.parse. So do numbers and booleans, and remember that '0' and 'false' are both truthy strings once read back.

What belongs in it

Small, non-secret values: the chosen theme, a locale, an onboarding flag, the last tab. Not secrets, because nothing here is encrypted — tokens belong in expo-secure-store. Not images or long lists, because on Android the whole store shares one database. Not data you want to filter or sort, because there is no query — that is what SQLite is for.

The drop-in alternative

Expo ships expo-sqlite/kv-store with the same method names plus synchronous variants such as getItemSync. Swapping the import is the entire migration.

Example

Example · tsx
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

const THEME_KEY = 'settings.theme';

type Theme = 'light' | 'dark';

export default function ThemeToggle() {
  const [theme, setTheme] = useState<Theme>('light');
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    // The read is async, so this runs after the first paint.
    AsyncStorage.getItem(THEME_KEY).then((value) => {
      if (value === 'dark' || value === 'light') {
        setTheme(value);
      }
      setLoaded(true);
    });
  }, []);

  async function choose(next: Theme) {
    setTheme(next); // update the screen first
    await AsyncStorage.setItem(THEME_KEY, next); // then write the string
  }

  // The early return sits below every hook call, never above one.
  if (!loaded) {
    return <View style={styles.screen} />;
  }

  return (
    <View style={styles.screen}>
      <Text style={styles.label}>Theme: {theme}</Text>
      <Pressable
        style={styles.button}
        onPress={() => choose(theme === 'light' ? 'dark' : 'light')}
      >
        <Text style={styles.buttonText}>Switch</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
  label: { fontSize: 18 },
  button: { backgroundColor: '#1f6feb', paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8 },
  buttonText: { color: '#ffffff', fontSize: 16 },
});

When to use it

  • Remember the theme or locale the user picked so the next launch opens the right way.
  • Store a 'seen onboarding' flag so the tour shows exactly once.
  • Keep the last known list of items so the screen has something to show before a request finishes.
  • Persist a draft text field so a backgrounded app does not lose what was typed.

More examples

A typed JSON wrapper

Stringify and parse in one place rather than at every call site. The try/catch matters: a half-written or outdated entry makes JSON.parse throw, and an unhandled throw at startup looks like a crash on launch. Note that `as T` is a claim you make to the compiler, not a check at runtime — the stored shape can be whatever an older build wrote.

Example · tsx
import AsyncStorage from '@react-native-async-storage/async-storage';

export async function saveJson<T>(key: string, value: T): Promise<void> {
  await AsyncStorage.setItem(key, JSON.stringify(value));
}

export async function loadJson<T>(key: string, fallback: T): Promise<T> {
  const raw = await AsyncStorage.getItem(key);
  if (raw === null) {
    return fallback;
  }
  try {
    return JSON.parse(raw) as T;
  } catch {
    // An entry written by an older version of the app can be unparseable.
    await AsyncStorage.removeItem(key);
    return fallback;
  }
}

Reading several keys at once

multiGet returns an array of [key, value] pairs, which a Map accepts directly. Keys that were never written come back with a null value rather than being left out. Reach for the multi* methods on startup: one native round trip for ten settings is noticeably cheaper than ten awaits in a row.

Example · tsx
import AsyncStorage from '@react-native-async-storage/async-storage';

async function loadSettings() {
  await AsyncStorage.multiSet([
    ['settings.theme', 'dark'],
    ['settings.locale', 'en-GB'],
  ]);

  const rows = await AsyncStorage.multiGet([
    'settings.theme',
    'settings.locale',
    'settings.units',
  ]);

  const map = new Map(rows);
  console.log(map.get('settings.theme'), map.get('settings.units'));
  // Output: dark null
}

Swapping in expo-sqlite/kv-store

Expo's key-value store is backed by SQLite and exposes the same method names, so the import line is the whole change. The synchronous getters are the reason to bother: a value read during the first render means no flash of the default theme on launch. They block the JavaScript thread, so use them for a few small values, not in a list row.

Example · tsx
// Before:
// import Storage from '@react-native-async-storage/async-storage';
import Storage from 'expo-sqlite/kv-store';

// Same async methods as AsyncStorage:
await Storage.setItem('settings.theme', 'dark');
const theme = await Storage.getItem('settings.theme');

// Plus synchronous variants, which AsyncStorage does not have:
const themeNow = Storage.getItemSync('settings.theme');

Discussion

  • Be the first to comment on this lesson.