skip to content

In an Expo app, what do expo-sqlite's SQLiteProvider and useSQLiteContext do, and what renders before the database is ready?

level: juniorimportance: must knowfreq 45%

answer

  1. one database for a subtree
  2. context instead of prop drilling
  3. import, open, onInit, then children
  4. default renders null while loading
  5. hook throws outside the provider

basics

~20 s

SQLiteProvider opens one database for its subtree, importing an assetSource first and then running onInit, and puts it in React context; useSQLiteContext returns it in any descendant. By default the provider renders nothing until that work finishes.

solid answer

~40 s

`<SQLiteProvider databaseName="birds.db" onInit={migrate}>` opens the file once for everything below it: it imports a bundled `assetSource` if given, calls `openDatabaseAsync`, awaits `onInit`, and only then renders its children with the database in context. Any screen calls `const db = useSQLiteContext()` and gets that same `SQLiteDatabase`; outside a provider the hook throws `useSQLiteContext must be used within a <SQLiteProvider>`. In the default mode the provider renders `null` while it loads, so there is no spinner unless you use `useSuspense` with a Suspense fallback. Because children render only after `onInit`, no screen can query a half-migrated schema. Errors are rethrown during render unless you pass `onError`, and unmounting the provider closes the database.

code

tsx · 26 lines
tsx
import { SQLiteProvider, useSQLiteContext, type SQLiteDatabase } from 'expo-sqlite';
import { useEffect, useState } from 'react';
import { Text } from 'react-native';

async function migrate(db: SQLiteDatabase): Promise<void> {
  await db.execAsync(
    'CREATE TABLE IF NOT EXISTS sightings (id INTEGER PRIMARY KEY NOT NULL, species TEXT NOT NULL, seen_at INTEGER NOT NULL)'
  );
}

function SightingCount() {
  const db = useSQLiteContext();
  const [count, setCount] = useState(0);
  useEffect(() => {
    db.getFirstAsync<{ n: number }>('SELECT COUNT(*) AS n FROM sightings').then((r) => setCount(r?.n ?? 0));
  }, [db]);
  return <Text>{count} sightings logged</Text>;
}

export default function App() {
  return (
    <SQLiteProvider databaseName="birds.db" onInit={migrate}>
      <SightingCount />
    </SQLiteProvider>
  );
}

go deeper

for a junior

Recall the pair: SQLiteProvider opens and shares one database, useSQLiteContext reads it, and the hook throws outside a provider.

for a middle

Explain the setup order, import then open then onInit then children, and why rendering null until then means no screen can query an unmigrated schema.

for a senior

Place the provider at the root, decide how loading and setup errors are shown, and know it neither caches results nor refreshes screens on its own.

for a principal

Decide what sits on top of the provider, plain queries, a repository layer or an ORM, so screens stay independent of SQL as the app grows.

## What problem the provider solves An Expo app that stores data with `expo-sqlite` needs one open database object that many screens share: the sighting log, the sighting form, a statistics tab. Opening the file in every screen, or passing the database down as a prop through every layer, is repetitive and makes it easy for a screen to query before the schema is ready. `SQLiteProvider` is expo-sqlite's React component for this. It opens the database once, prepares it, and publishes it through React context. `useSQLiteContext()` is the hook that reads it back in any descendant. ## What the provider does, in order In expo-sqlite 57 the provider's setup runs these steps before rendering its children: 1. **Import** a bundled database file, if you passed `assetSource`. 2. **Open** the file named by `databaseName` (in `directory`, or the default database directory) with `openDatabaseAsync`, using the `options` prop as open options. 3. **Initialise** by awaiting `onInit(db)`, if you passed one. This is where the app's schema migrations go. 4. **Render** the children inside a context provider whose value is the open `SQLiteDatabase`. Props you meet in practice: | Prop | Purpose | |---|---| | `databaseName` | file name to open, required | | `directory` | where the file lives; defaults to expo-sqlite's database directory | | `options` | open options such as `enableChangeListener` | | `assetSource` | a bundled `.db` file to copy in first | | `onInit` | async setup run after opening, before children render | | `onError` | handler for setup errors; the default rethrows | | `useSuspense` | integrate with React Suspense instead of rendering `null` | ## What renders while it loads In the default (non-Suspense) mode, the provider keeps a loading flag in state and **returns `null`** until the database is open and `onInit` has resolved. The children are not mounted at all during that time, which has two consequences: - There is **no built-in loading UI.** For a spinner or skeleton, either keep the native splash screen up until the tree is ready, or switch to `useSuspense` and wrap the provider in a `Suspense` boundary with a `fallback`. - A child **can never run a query early.** `useSQLiteContext()` is only callable once the database exists, so no screen reads a schema that migrations have not finished. ## Reading the database in a screen ```tsx const db = useSQLiteContext(); const rows = await db.getAllAsync<Sighting>('SELECT * FROM sightings'); ``` The hook returns the same `SQLiteDatabase` object to every caller. It has no loading state of its own: it either returns the database or, when there is no `SQLiteProvider` above the component, **throws** an error saying it must be used within one. A component rendered in a different tree, such as a screen registered outside the provider, is the usual cause. ## Errors and teardown - If opening or `onInit` fails, the provider stores the error and, with no `onError`, **rethrows it during render**, so the nearest error boundary (or the development error screen) shows it. Passing `onError` lets you log it and render your own recovery. - When the provider unmounts, its effect cleanup calls `closeAsync()` on the database. Place the provider high in the tree, such as the root layout, so it lives as long as the app. ## One provider, or several A `SQLiteProvider` publishes exactly one database through one React context. Two consequences follow for apps with more than one file: - **Nesting shadows.** If a second provider is nested inside the first, `useSQLiteContext()` returns the **nearest** one. Components below the inner provider cannot reach the outer database through the hook. - **Side by side is fine.** Two providers in separate subtrees each serve their own children, as long as no screen needs both. For a screen that needs two files, open the second one yourself with `openDatabaseAsync` in a small module of your own, or keep one main database and attach the second with SQL. Most apps need only one provider at the root. ## Where the provider stops The provider is plumbing, not a data layer: - It does not re-run queries when data changes; live updates need the change listener (`enableChangeListener` plus `addDatabaseChangeListener`). - It does not cache query results; each `getAllAsync` reads the file again. - It does not decide your schema; `onInit` runs whatever migration code you give it.

  • How do you show a loading screen while SQLiteProvider opens the database?
    The default mode renders `null`, so either keep the native splash screen visible until the tree is ready, or pass `useSuspense` and wrap the provider in a React `Suspense` boundary whose `fallback` shows while the database opens and `onInit` runs.
  • What happens to the database when the SQLiteProvider unmounts?
    Its effect cleanup calls `closeAsync()` on the database it opened. That is why the provider belongs near the root of the app: mounting it inside a screen that comes and goes would close and reopen the database, and rerun `onInit`, on every visit.

saying these in an interview costs you the question

  • useSQLiteContext returns null outside a provider, so just null-check it
  • Children render immediately and can query while onInit is still running
  • SQLiteProvider shows a built-in loading spinner while it opens
  • The database stays open after its SQLiteProvider unmounts
  • The provider re-runs screen queries automatically when data changes