skip to content

Async Storage

Async Storage is React Native's unencrypted, promise-based key-value store for small string values. Interviewers ask what belongs in it and when to move to SQLite, MMKV or secure storage instead.

on this pageshow

explore

questions

5

With React Native Async Storage v3, how do you persist an onboarding-completed flag and a preferred-language object when the store only accepts strings?

level: juniorimportance: must knowfreq 72%

answer

  1. strings in, strings out
  2. stringify before setItem
  3. getItem resolves null when missing
  4. every call returns a Promise
  5. parse defensively on read

basics

~20 s

Async Storage stores only strings, so serialize with JSON.stringify before setItem and JSON.parse after getItem, treating a null result as never saved. Every method returns a Promise, so await the read before deciding which screen to show.

solid answer

~30 s

Async Storage is a key-value store whose values are strings: in v3 the signature is `setItem(key: string, value: string)`. For a flag I write `JSON.stringify(true)` and for a language object `JSON.stringify({ code: 'de' })`, usually on a named instance from `createAsyncStorage('preferences')`. On launch I `await getItem(...)`: a missing key resolves to `null`, which I treat as 'show onboarding' or 'use the device language'. I wrap `JSON.parse` in a `try`, because a corrupt or hand-edited value throws a `SyntaxError`. The classic bugs are forgetting the `await` (a Promise is always truthy) and comparing against the string `'false'`, which is also truthy.

code

tsx · 31 lines
tsx
import { useEffect, useState } from "react";
import { ActivityIndicator } from "react-native";
import { createAsyncStorage } from "@react-native-async-storage/async-storage";
import { OnboardingScreen } from "./OnboardingScreen";
import { HomeScreen } from "./HomeScreen";

const prefs = createAsyncStorage("preferences");

export function StartupGate() {
  const [done, setDone] = useState<boolean | null>(null);

  useEffect(() => {
    prefs
      .getItem("onboardingDone")
      .then((raw) => setDone(raw === JSON.stringify(true)))
      .catch(() => setDone(false));
  }, []);

  if (done === null) return <ActivityIndicator />;
  if (!done) {
    return (
      <OnboardingScreen
        onFinish={async () => {
          await prefs.setItem("onboardingDone", JSON.stringify(true));
          setDone(true);
        }}
      />
    );
  }
  return <HomeScreen />;
}

go deeper

for a junior

Recall that values are strings only, that every method returns a Promise, and that a missing key resolves to null. Show the stringify-on-write, parse-on-read pair for a flag and an object.

for a middle

Explain why the first render has no stored value yet and how a loading state or held splash screen prevents a flash. Mention that setItem overwrites and removeItem on a missing key is a no-op.

for a senior

Push serialization into one typed module that validates parsed data, because stored values outlive the code that wrote them and a later release may meet a shape it never wrote.

for a principal

Frame the startup read as a product cost: every awaited preference delays the first meaningful screen, so decide which values justify blocking launch and which can load after first paint.

## What Async Storage actually stores **Async Storage** (`@react-native-async-storage/async-storage`) is a persistent, unencrypted **key-value store** for React Native. In version 3 you create a named store with `createAsyncStorage(name)` and call methods on it. The typed interface is explicit about values: - `getItem(key: string): Promise<string | null>` - `setItem(key: string, value: string): Promise<void>` - `removeItem(key: string): Promise<void>` There is no boolean, number or object overload. Passing `true` or `{ code: 'de' }` to `setItem` is a TypeScript error, and the library's own FAQ says to serialize non-string values with `JSON.stringify` and read them back with `JSON.parse`. ## Writing the onboarding flag and the language The usual design is one small preferences store with two keys: ```typescript import { createAsyncStorage } from "@react-native-async-storage/async-storage"; const prefs = createAsyncStorage("preferences"); export async function completeOnboarding(): Promise<void> { await prefs.setItem("onboardingDone", JSON.stringify(true)); } export async function saveLanguage(code: string): Promise<void> { await prefs.setItem("language", JSON.stringify({ code })); } ``` `setItem` **overwrites** an existing value for the same key, so calling it again when the user changes language simply replaces the old choice. `removeItem` on a key that does not exist does nothing and does not throw, which makes a "reset onboarding" debug action trivial. ## Reading them back at startup Reads are asynchronous: the value lives in a database on disk and comes back through a Promise. That has three consequences: 1. **You must `await`** before branching. `if (prefs.getItem("onboardingDone"))` tests a Promise object, which is always truthy, so every user skips onboarding. 2. **The first render has no value yet.** Keep a `loading` state (or hold the splash screen) until the read resolves, otherwise the onboarding screen flashes for users who already finished it. 3. **A missing key resolves to `null`**, not `undefined` and not an empty string. Treat `null` as "never saved" and fall back to a default. ```typescript export async function loadLanguage(): Promise<string | null> { const raw = await prefs.getItem("language"); if (raw === null) return null; try { const parsed = JSON.parse(raw) as { code?: string }; return typeof parsed.code === "string" ? parsed.code : null; } catch { return null; } } ``` ## The string traps, side by side | What you wrote | What is stored | What goes wrong on read | |---|---|---| | `setItem("done", "false")` | `"false"` | `if (value)` is true, because any non-empty string is truthy | | `setItem("lang", String({ code: "de" }))` | `"[object Object]"` | `JSON.parse` throws, the choice is lost | | `setItem("count", String(3))` | `"3"` | `value + 1` gives `"31"` unless you convert it back | | `setItem("done", JSON.stringify(true))` | `"true"` | parses back to the boolean `true`, as intended | Two habits remove most of these bugs: - Keep the serialization in **one small module** (as above) so screens never touch raw strings. - **Validate after parsing**: stored data outlives the code that wrote it, and a later app version may read a shape an earlier one never wrote. ## Why a named instance In v3 `createAsyncStorage("preferences")` gives an **isolated storage area**: its own SQLite database on iOS and Android, its own IndexedDB database on the web. Keys in `preferences` cannot collide with keys another part of the app writes into, say, a `cache` instance, and `clear()` on one instance leaves the others alone. The package's default export still exists, but it points at the older v2 storage and is meant as a migration path, not for new code. ## Reading both values in one call At launch the app usually needs the flag and the language together. Instead of two `getItem` calls, `getMany(["onboardingDone", "language"])` returns an object that always contains both keys, with `null` for whichever was never written. That is one trip into native code instead of two, and the result shape makes the defaults obvious: ```typescript const { onboardingDone, language } = await prefs.getMany(["onboardingDone", "language"]); const showOnboarding = onboardingDone !== JSON.stringify(true); ``` ## Where this stops being the right tool A flag and a language code are the textbook fit: tiny, non-sensitive, read once at launch. The same technique becomes a smell when the JSON string grows into a large document rewritten on every change, when a value is a secret (Async Storage is not encrypted), or when the startup `await` itself is the problem. Those are the points where teams reach for other stores, which are separate topics.

  • Why not store the flag as the string 'false' when onboarding is incomplete?
    Any non-empty string is truthy, so `if (value)` treats `'false'` as done. Either store `JSON.stringify(true)` only when finished and treat `null` as not done, or always parse the value back to a boolean before testing it. Comparing against one exact serialized value is the least error-prone option.
  • What happens on first launch when the language key has never been written?
    `getItem` resolves to `null`; it does not throw and does not return `undefined`. The app should fall back to the device locale or a default and only write the key once the user chooses. Calling `JSON.parse(null)` happens to return `null`, but relying on that coercion hides the intent, so check for `null` explicitly.
  • How do you avoid the onboarding screen flashing for returning users?
    The read is asynchronous, so the first render cannot know the answer. Keep a tri-state value (`null` while loading, then `true` or `false`) and render a spinner or keep the splash screen visible until the read resolves, then choose the screen.

saying these in an interview costs you the question

  • Passing a boolean or object straight to setItem and expecting it back typed
  • Branching on getItem without await, testing a Promise instead of a value
  • Expecting getItem to return undefined or throw for a missing key
  • Storing the string 'false' and testing it for truthiness
  • Calling JSON.parse without handling a corrupt stored value
open as a page

Where does React Native Async Storage v3 keep data on iOS, Android and the web, and what does its lack of encryption rule out?

level: middleimportance: must knowfreq 66%

basics

~20 s

Async Storage v3 writes each instance to its own plaintext SQLite file inside the app's sandbox on iOS and Android, and to an IndexedDB database on the web. Nothing is encrypted by the library, so tokens, passwords and personal data do not belong there.

open as a page

In React Native Async Storage v3, how do getMany, setMany and removeMany behave, and how should you handle the AsyncStorageError they can throw?

level: middleimportance: should knowfreq 34%

basics

~20 s

getMany returns an object containing every requested key, null for missing ones; setMany takes a key-to-string record; removeMany ignores absent keys. Each batch is atomic, and any failure throws AsyncStorageError, whose type property says which layer failed.

open as a page

What changed in React Native Async Storage v3 compared with v2, and why does the package still ship a default export?

level: middleimportance: should knowfreq 42%

basics

~20 s

Async Storage v3 replaces the global singleton with isolated instances from createAsyncStorage(name), drops callbacks, the merge API and useAsyncStorage, renames multiGet/multiSet/multiRemove to getMany/setMany/removeMany, and throws AsyncStorageError. The default export stays so existing v2 data remains readable during migration.

open as a page

A React Native app's Async Storage usage has grown beyond a few preferences; what signals say it is time to move data to MMKV or SQLite, and how do you migrate it safely?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Move when startup waits on awaited reads (synchronous MMKV fits), when large JSON strings are rewritten for one-field changes or filtered in JavaScript (SQLite fits), or when secrets crept in. Migrate once at startup: copy, verify, write a marker, then delete the old keys.

open as a page