Keeping data on the phone lets a React Native app work without a network, and SQLite can store it on the device. Picking a library comes first: react-native-sqlite-storage hasn’t had a release since October 2021. In this article I’ll walk you through a small offline to-do app with expo-sqlite, and how the older library’s executeSql takes its parameters for code that still uses it.
TLDR
- For a new app, use
expo-sqlite. Install it withnpx expo install expo-sqlite. - Open the database with
SQLiteProvider(oropenDatabaseAsync), then userunAsyncfor writes andgetAllAsyncfor reads. - Pass values with
?placeholders, never by building the SQL string yourself. - In react-native-sqlite-storage,
executeSql(sql, params)takes the values as an array:executeSql('… WHERE id = ?', [id]).
What is React Native SQLite and what will we build?
React Native has no built-in SQL database, so apps add one through a native library. Each library gives JavaScript an API to run SQL against a file on the phone, using either the phone’s own SQLite or a copy bundled with the app. The data stays there when the app restarts or goes offline.
The options and their state on npm, checked on 7 October 2026:
| Library | Latest version | Last release | Notes |
|---|---|---|---|
| expo-sqlite | 57.0.4 | October 2026 | Maintained by Expo. Works in Expo Go and in bare React Native with Expo modules |
| @op-engineering/op-sqlite | 18.2.5 | September 2026 | See React Native op-sqlite |
| react-native-sqlite-storage | 6.0.1 | October 2021 | Callback/promise API in the WebSQL style |
To show you the current approach, I will build a to-do list with expo-sqlite. You can add items and tick them off, and they survive a restart.
Prerequisites
| Requirement | Version used here |
|---|---|
| Node.js | 24.18.0 |
| React Native | 0.87.1 |
| expo-sqlite | 57.0.4 |
| TypeScript | 7.0.2, to type-check the code |
I didn’t run the app on a phone or simulator. Instead I ran every SQL statement in the sqlite3 shell, and type-checked the app code against the installed libraries.
Building the offline to-do app
The app has two files: a database layer with all the SQL, and one screen.
Step 1: Install expo-sqlite
In an Expo project, run this (from the expo-sqlite docs; not executed here):
npx expo install expo-sqlite
In a bare React Native app, install Expo modules first with npx install-expo-modules@latest (installing Expo modules).
Step 2: Write the database layer
All the SQL lives in one file. migrate runs when the database opens. It uses PRAGMA user_version to remember that the table already exists, so the setup runs only on the first launch:
import * as SQLite from 'expo-sqlite';
export type Todo = { id: number; title: string; done: number };
// Runs once when the database opens: creates the table on the first launch.
export async function migrate(db: SQLite.SQLiteDatabase) {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
if ((row?.user_version ?? 0) >= 1) return;
await db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
);
PRAGMA user_version = 1;
`);
}
export async function addTodo(db: SQLite.SQLiteDatabase, title: string) {
const result = await db.runAsync('INSERT INTO todos (title) VALUES (?)', title);
return result.lastInsertRowId;
}
export function getTodos(db: SQLite.SQLiteDatabase) {
return db.getAllAsync<Todo>('SELECT id, title, done FROM todos ORDER BY id');
}
export async function toggleTodo(db: SQLite.SQLiteDatabase, id: number) {
await db.runAsync('UPDATE todos SET done = 1 - done WHERE id = ?', id);
}
export async function deleteTodo(db: SQLite.SQLiteDatabase, id: number) {
await db.runAsync('DELETE FROM todos WHERE id = ?', id);
}
Each function passes its values with ? placeholders. runAsync and getAllAsync take them as extra arguments, or as an array.
Step 3: Connect the screen
SQLiteProvider opens todos.db and runs migrate. Any component inside it gets the database from useSQLiteContext(). Tap an item to tick it off, and long-press to delete it:
import { useCallback, useEffect, useState } from 'react';
import { Button, FlatList, Pressable, Text, TextInput, View } from 'react-native';
import { SQLiteProvider, useSQLiteContext } from 'expo-sqlite';
import { addTodo, deleteTodo, getTodos, migrate, toggleTodo, type Todo } from './db';
export default function App() {
return (
<SQLiteProvider databaseName="todos.db" onInit={migrate}>
<TodoScreen />
</SQLiteProvider>
);
}
function TodoScreen() {
const db = useSQLiteContext();
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState('');
const refresh = useCallback(async () => setTodos(await getTodos(db)), [db]);
useEffect(() => { refresh(); }, [refresh]);
return (
<View style={{ padding: 24 }}>
<TextInput value={title} onChangeText={setTitle} placeholder="New todo" />
<Button
title="Add"
onPress={async () => { await addTodo(db, title); setTitle(''); await refresh(); }}
/>
<FlatList
data={todos}
keyExtractor={(t) => String(t.id)}
renderItem={({ item }) => (
<Pressable
onPress={async () => { await toggleTodo(db, item.id); await refresh(); }}
onLongPress={async () => { await deleteTodo(db, item.id); await refresh(); }}
>
<Text>{item.done ? '✓ ' : '○ '}{item.title}</Text>
</Pressable>
)}
/>
</View>
);
}
Running it
On a phone, start the app with npx expo start (not executed here). These are the same statements the app runs, in the sqlite3 shell. Here one item is ticked off and the other deleted:
sqlite> CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
);
sqlite> INSERT INTO todos (title) VALUES ('Buy milk'); INSERT INTO todos (title) VALUES ('Call the bank');
sqlite> UPDATE todos SET done = 1 - done WHERE id = 1;
sqlite> SELECT id, title, done FROM todos ORDER BY id;
id title done
-- ------------- ----
1 Buy milk 1
2 Call the bank 0
sqlite> DELETE FROM todos WHERE id = 2; SELECT id, title, done FROM todos ORDER BY id;
id title done
-- -------- ----
1 Buy milk 1
The code compiles against the installed libraries:
$ npx tsc -p tsconfig.json && echo "no type errors"
no type errors
Using react-native-sqlite-storage (older code)
If you’re maintaining an app built on react-native-sqlite-storage, its executeSql takes the SQL and then an array of values. Each ? gets the next value in the array. With enablePromise(true), it returns a promise of an array of result sets:
import SQLite from 'react-native-sqlite-storage';
SQLite.enablePromise(true);
export async function openLegacy() {
const db = await SQLite.openDatabase({ name: 'todos.db', location: 'default' });
await db.executeSql(
'CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)'
);
return db;
}
// executeSql(sql, params): each ? in the SQL takes the next value from the params array.
export async function addTodoLegacy(db: SQLite.SQLiteDatabase, title: string) {
const [result] = await db.executeSql('INSERT INTO todos (title, done) VALUES (?, ?)', [title, 0]);
return result.insertId;
}
export async function getTodosLegacy(db: SQLite.SQLiteDatabase) {
const [result] = await db.executeSql('SELECT id, title, done FROM todos WHERE done = ?', [0]);
return result.rows.raw();
}
Passing the values without the array is the classic mistake. TypeScript catches it:
$ npx tsc -p tsconfig.json # params passed without the array
legacy-db.ts(15,9): error TS2488: Type 'Promise<[ResultSet]> & void' must have a '[Symbol.iterator]()' method that returns an iterator.
legacy-db.ts(15,89): error TS2345: Argument of type 'string' is not assignable to parameter of type 'any[]'.
result.rows.raw() returns the rows as a plain array. The library README also shows the callback style, tx.executeSql(sql, [], (tx, results) => …) inside db.transaction.
Common problems
None of these were reproduced on a device. The first follows from how SQLiteProvider opens a file by name, and the others come from the expo-sqlite docs.
- Data disappears after an app update: the table was created in a different database file name. Keep
databaseNamethe same across releases. - Schema changes on existing installs:
CREATE TABLE IF NOT EXISTSwon’t add new columns to an old table. BumpPRAGMA user_versionand run ALTER TABLE … ADD COLUMN inmigrate, as the expo-sqlite docs show. execAsyncwith user input: the expo-sqlite docs warn thatexecAsync()“does not escape parameters and may lead to SQL injection”. UserunAsyncwith placeholders for anything that comes from the user.- expo-sqlite on web: it needs extra Metro and HTTP header setup for WebAssembly (web setup).
Conclusion
For a new React Native app, expo-sqlite is the maintained choice, and its async API maps directly onto SQL. Keep every query in one module with placeholders, and version the schema with PRAGMA user_version. Reach for react-native-sqlite-storage only when you’re maintaining code that already uses it.
Further reading
- React Native op-sqlite
- Expo SQLite
- iOS SQLite
- SQLite in Node.js
- SQLite ALTER TABLE ADD COLUMN
- Library docs: expo-sqlite, react-native-sqlite-storage
Tested with SQLite 3.51.0 (SQL) and TypeScript 7.0.2 against expo-sqlite 57.0.4 and react-native-sqlite-storage 6.0.1 on 7 October 2026. Not run on a device.
