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?
answer
- strings in, strings out
- stringify before setItem
- getItem resolves null when missing
- every call returns a Promise
- parse defensively on read
basics
~20 sAsync 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 sAsync 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 linesimport { 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
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.
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.
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.
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