React Native Intermediate: Navigation, Data and Device Features
Saving Data on the Device
In this lesson you will
- Save and load small values with AsyncStorage, using JSON for objects
- Choose SecureStore for secrets such as tokens, and explain why AsyncStorage is not safe for them
- Load saved data when the app starts without showing a flash of empty content
Introduction
State from useState lives in memory. When the user closes the app, it is gone. To remember things, such as a dark mode choice, a to-do list or a login, you must persist them: save them on the phone's storage so they survive restarts. This lesson covers the two simplest tools, and tells you when you have outgrown them.
AsyncStorage: small values, saved for good
AsyncStorage is a key-value store: you save a string under a name (the key) and read it back later. The Expo docs describe it as "asynchronous, unencrypted, persistent, key-value storage." Install it with:
Terminal
npx expo install @react-native-async-storage/async-storageThe important words:
- Asynchronous: every call returns a promise, so you use
await. - Persistent: the data stays after the app closes.
- Unencrypted: anyone who can read the phone's files could read it. Never store passwords or tokens here.
It stores strings only. For objects and arrays, convert with JSON.stringify when saving and JSON.parse when loading.
React (TSX)
import AsyncStorage from "@react-native-async-storage/async-storage";
export type Settings = { darkMode: boolean; name: string };
const KEY = "settings";
export async function saveSettings(settings: Settings): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(settings));
}
export async function loadSettings(): Promise<Settings | null> {
const raw = await AsyncStorage.getItem(KEY);
if (raw === null) return null;
try {
return JSON.parse(raw) as Settings;
} catch {
return null;
}
}
export async function clearSettings(): Promise<void> {
await AsyncStorage.removeItem(KEY);
}setItem(key, value)saves.getItem(key)returns the string, ornullif nothing was saved.removeItem(key)deletes.JSON.parsecan throw if the saved text is damaged, so wrap it intryand fall back to a default.
Quick quiz
Why must objects be converted with JSON.stringify before saving to AsyncStorage?
Loading at startup
Because loading is asynchronous, your screen draws before the saved data arrives. If you start with an empty value, the user sees a flash of the wrong content. The fix is a loaded flag: show nothing (or a spinner) until the data is read, then show the real thing.
React (TSX)
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
const KEY = "todos";
export default function Todos() {
const [todos, setTodos] = useState<string[]>([]);
const [draft, setDraft] = useState("");
const [loaded, setLoaded] = useState(false);
// Read the saved list once, when the screen first appears.
useEffect(() => {
AsyncStorage.getItem(KEY)
.then((raw) => {
if (raw) setTodos(JSON.parse(raw) as string[]);
})
.finally(() => setLoaded(true));
}, []);
// Save the list every time it changes, but only after the first load.
useEffect(() => {
if (loaded) AsyncStorage.setItem(KEY, JSON.stringify(todos));
}, [todos, loaded]);
if (!loaded) return <ActivityIndicator style={{ flex: 1 }} />;
return (
<View style={styles.screen}>
<TextInput style={styles.input} value={draft} onChangeText={setDraft} placeholder="New to-do" />
<Pressable
style={styles.button}
onPress={() => {
if (draft.trim()) setTodos([...todos, draft.trim()]);
setDraft("");
}}
>
<Text style={styles.label}>Add</Text>
</Pressable>
{todos.map((todo, i) => (
<Text key={i} style={styles.item}>
{todo}
</Text>
))}
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 12 },
input: { borderWidth: 1, borderColor: "#ccc", borderRadius: 8, padding: 12 },
button: { backgroundColor: "#5b4bf5", padding: 12, borderRadius: 8, alignItems: "center" },
label: { color: "white", fontWeight: "bold" },
item: { fontSize: 16 },
});The most important detail is if (loaded) in the second effect. Without it, the first render (with an empty list) would save an empty list and erase what was stored, before the first effect could read it.
Quick quiz
Why does the save effect check that loaded is true?
SecureStore: secrets
Some data must not sit in plain storage: login tokens, API keys, anything someone could use to pretend to be your user. SecureStore (expo-secure-store) is built for that. Per the docs it stores values in the iOS Keychain and, on Android, encrypts them with the Android Keystore.
Terminal
npx expo install expo-secure-storeReact (TSX)
import * as SecureStore from "expo-secure-store";
const TOKEN_KEY = "auth-token";
export async function saveToken(token: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function readToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export async function signOut(): Promise<void> {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}The names end in Async because they return promises. getItemAsync resolves to null when nothing is stored. Some points from the docs:
- Keep values small, such as a token. The docs warn that large values can be refused by the platform.
- It is not a backup. Data on Android is not preserved when the app is uninstalled, so never treat it as the only copy of something precious.
- It is still not a place for passwords you could avoid storing. Prefer short-lived tokens from your server.
When you need a real database
AsyncStorage is great for settings and small lists. It becomes awkward when you have thousands of records, need to search or sort them, or have related data (users with orders). For that, use a real database. expo-sqlite gives you SQLite on the phone, the same SQL you learned in the databases courses on this site.
| Need | Use |
|---|---|
| A setting, a flag, a small list | AsyncStorage |
| A login token or secret | SecureStore |
| Lots of structured records, searching, relations | expo-sqlite |
Try it yourself
Extend the to-do app: add a delete button on each item, and a "dark mode" switch saved with a settings helper. Then store a fake login token with SecureStore and read it back when the app starts to decide which screen to show.
Summary
- Persisting means saving data on the phone so it survives the app closing. AsyncStorage stores strings by key, so use JSON for objects, and it is not encrypted.
- Load saved data in an effect, track a
loadedflag, and do not save until loading has finished. - Keep tokens and secrets in SecureStore, and move to expo-sqlite when you have many related records.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.
Sources and further reading