Section 4 · Data and the Device
Storing Data on the Device
21 min3 min readChoose wisely
Lesson at a glance
Choose between AsyncStorage, expo-sqlite and expo-secure-store for each kind of data in Trailhead, and use each one correctly, from preferences to a migrated hike database to the sign-in token.
- 1AsyncStorage for preferences
- 2expo-sqlite for the hike log
- 3SecureStore for secrets
- 4Files belong on disk
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
A hiking app that forgets your log when you lose signal, or asks you to sign in every morning, gets deleted. Trailhead has three kinds of data to keep on the device, and each fits a different tool:
| Data | Example | Tool |
|---|---|---|
| Small preferences | units (km or mi), last-used tab | AsyncStorage |
| Structured records you query | the hike log, sorted and filtered | expo-sqlite |
| Secrets | the sign-in refresh token | expo-secure-store |
Picking the wrong one rarely fails loudly. It fails as slow screens, lost data after an update, or a security review you don’t pass.
AsyncStorage for preferences
AsyncStorage is a persistent key-value store of strings. Every call is async, and nothing is encrypted:
import AsyncStorage from '@react-native-async-storage/async-storage';
export type Prefs = { units: 'km' | 'mi'; remindersOn: boolean };
const KEY = 'trailhead.prefs.v1';
const defaults: Prefs = { units: 'km', remindersOn: false };
export async function loadPrefs(): Promise<Prefs> {
const raw = await AsyncStorage.getItem(KEY);
if (!raw) return defaults;
try {
return { ...defaults, ...JSON.parse(raw) };
} catch {
return defaults;
}
}
export function savePrefs(prefs: Prefs) {
return AsyncStorage.setItem(KEY, JSON.stringify(prefs));
}Store one JSON blob under a versioned key rather than many tiny keys, merge with defaults when reading (so a new preference added in v2 gets a sensible value for old users), and survive corrupt data with a try. Install it with npx expo install @react-native-async-storage/async-storage. If your app already uses expo-sqlite, expo-sqlite/kv-store offers the same API backed by SQLite, so you can skip the extra dependency.
Read preferences once at startup, keep them in a context like the hikes, and write them back when they change. Calling getItem inside components on every render turns a fast screen into one that flickers through default values first.
AsyncStorage gets slow and awkward when you start storing lists of records in it: every read parses the whole blob, and there’s no way to ask for “hikes over 10 km in 2026” without loading everything.
expo-sqlite for the hike log
The hike log is structured data you sort, filter and total, which is what a database is for. expo-sqlite gives you a real SQLite database file on the device. SQLiteProvider opens it and runs your setup before any child renders:
import { Stack } from 'expo-router';
import { SQLiteProvider, type SQLiteDatabase } from 'expo-sqlite';
async function migrate(db: SQLiteDatabase) {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
let version = row?.user_version ?? 0;
if (version === 0) {
await db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE hikes (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
date TEXT NOT NULL,
distanceKm REAL NOT NULL,
favorite INTEGER NOT NULL DEFAULT 0
);
`);
version = 1;
}
if (version === 1) {
await db.execAsync('ALTER TABLE hikes ADD COLUMN photoUri TEXT');
version = 2;
}
await db.execAsync(`PRAGMA user_version = ${version}`);
}
export default function RootLayout() {
return (
<SQLiteProvider databaseName="trailhead.db" onInit={migrate}>
<Stack />
</SQLiteProvider>
);
}PRAGMA user_version is a number SQLite stores in the file for you. Each if block upgrades one step, so a user on version 0 runs both, and a user on version 1 runs only the second. Never edit an old step once it has shipped; add a new one.
Any screen can then use the database:
import { useSQLiteContext } from 'expo-sqlite';
const db = useSQLiteContext(); // in a component or custom hook
// …later, inside an async function such as an effect or a save handler:
const hikes = await db.getAllAsync<Hike>('SELECT * FROM hikes ORDER BY date DESC');
await db.runAsync(
'INSERT INTO hikes (id, name, date, distanceKm) VALUES (?, ?, ?, ?)',
hike.id, hike.name, hike.date, hike.distanceKm,
);In practice you’d call these from your reducer’s provider: load the hikes once into state, and write to SQLite whenever an action changes them.
SecureStore for secrets
The refresh token lets Trailhead sign in silently. Anyone who reads it can act as the user, so it goes in expo-secure-store, which encrypts values using the iOS Keychain and the Android Keystore:
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'trailhead.refreshToken';
export const saveToken = (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token);
export const readToken = () => SecureStore.getItemAsync(TOKEN_KEY);
export const clearToken = () => SecureStore.deleteItemAsync(TOKEN_KEY);This is what the isLoading state in the previous section’s useSession was waiting for: at startup, readToken() decides whether the user lands on the tabs or on the sign-in screen. SecureStore is meant for small values; some platform versions have rejected values over about 2 KB. Keep tokens and keys there, never whole objects or files. It doesn’t run on the web, so a web build needs a different approach.
Files belong on disk
A trail photo is several megabytes. Don’t put it in SQLite or AsyncStorage. Keep the file on disk (expo-file-system manages your app’s document directory) and store only its URI in the photoUri column. Your database stays small and fast, and the image component loads the file directly.
All of this data survives app updates, including over-the-air ones, and is deleted when the user uninstalls the app, with one quirk: on iOS, Keychain values from SecureStore can survive a reinstall, so don’t treat a missing database as proof that no token exists. If losing it would hurt, sync it to your server, which is exactly why Trailhead has accounts.
Next, the most delicate part of talking to the device: asking for permission to use its location and camera.
Key takeaways
- AsyncStorage is an unencrypted, asynchronous string key-value store, right for small preferences and wrong for secrets.
- expo-sqlite stores structured data you query, sort and filter; run schema migrations in
onInitusingPRAGMA user_version. - Always pass SQL values as parameters (
?), never by building the SQL string yourself. - expo-secure-store encrypts small values with the platform’s Keychain or Keystore; tokens and keys belong there.
- Store files such as photos on the file system and keep only their paths in the database.