In an Expo app, what do expo-sqlite's SQLiteProvider and useSQLiteContext do, and what renders before the database is ready?
answer
- one database for a subtree
- context instead of prop drilling
- import, open, onInit, then children
- default renders null while loading
- hook throws outside the provider
basics
~20 sSQLiteProvider 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 linesimport { 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
Recall the pair: SQLiteProvider opens and shares one database, useSQLiteContext reads it, and the hook throws outside a provider.
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.
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.
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