React Native SQLite: Offline Storage with expo-sqlite (Tested)

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 with npx expo install expo-sqlite.
  • Open the database with SQLiteProvider (or openDatabaseAsync), then use runAsync for writes and getAllAsync for 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:

LibraryLatest versionLast releaseNotes
expo-sqlite57.0.4October 2026Maintained by Expo. Works in Expo Go and in bare React Native with Expo modules
@op-engineering/op-sqlite18.2.5September 2026See React Native op-sqlite
react-native-sqlite-storage6.0.1October 2021Callback/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

RequirementVersion used here
Node.js24.18.0
React Native0.87.1
expo-sqlite57.0.4
TypeScript7.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 databaseName the same across releases.
  • Schema changes on existing installs: CREATE TABLE IF NOT EXISTS won’t add new columns to an old table. Bump PRAGMA user_version and run ALTER TABLE … ADD COLUMN in migrate, as the expo-sqlite docs show.
  • execAsync with user input: the expo-sqlite docs warn that execAsync() “does not escape parameters and may lead to SQL injection”. Use runAsync with 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

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.