In expo-sqlite, what changes when you set SQLiteProvider's useSuspense prop, and how do you then handle a failure to open or initialise?
answer
- default false
- a Suspense fallback replaces null
- React's use on a promise
- onError plus useSuspense throws
- one cached database at module scope
basics
~20 sWith useSuspense, SQLiteProvider suspends on the database promise, so the nearest React Suspense boundary shows its fallback until the database is open and onInit has run. onError cannot be combined with it; failures go to an error boundary instead.
solid answer
~50 sBy default `useSuspense` is `false`: the provider opens the database in an effect, renders `null` meanwhile, and routes errors to `onError` or rethrows them. With `useSuspense`, the provider calls React's `use()` on a promise that imports, opens and runs `onInit`, so you must place a `<Suspense fallback={...}>` above it; the fallback is your loading screen. If that promise rejects, the error is thrown to the nearest error boundary, and passing `onError` as well throws `Cannot use onError with useSuspense, use error boundaries instead`. One source-level caveat: the Suspense path keeps a single cached database promise at module scope, compared by `databaseName`, `directory`, and the identity of `options` and `onInit`. Asking for a different combination closes the cached database and opens the new one, so keep those props stable and do not run two Suspense-mode providers for different files.
code
tsx · 18 linesimport { SQLiteProvider } from 'expo-sqlite';
import { Suspense, type ReactNode } from 'react';
import { ActivityIndicator } from 'react-native';
import { migrate } from './db/migrate';
import { DatabaseErrorBoundary } from './DatabaseErrorBoundary';
export function BirdDatabase({ children }: { children: ReactNode }) {
return (
<DatabaseErrorBoundary>
<Suspense fallback={<ActivityIndicator />}>
<SQLiteProvider databaseName="birds.db" onInit={migrate} useSuspense>
{children}
</SQLiteProvider>
</Suspense>
</DatabaseErrorBoundary>
);
}go deeper
Recall that useSuspense defaults to false and that setting it requires a React Suspense boundary above the provider to show a fallback.
Explain the two error paths, onError versus an error boundary, and why the provider throws when both are configured.
Know the module-level cache: stable options and onInit identities, and only one Suspense-mode provider, or the database is closed and reopened under you.
Decide whether database readiness belongs in the app's general Suspense loading model or needs custom recovery that only onError allows.
## Two ways the provider can wait `SQLiteProvider` has work to do before its children can use the database: import a bundled file if one is given, open the database, and run `onInit`. The `useSuspense` prop (default `false`) chooses how the component tree waits for that work. | | Default (`useSuspense` false) | `useSuspense` | |---|---|---| | Waiting | effect plus state; renders `null` | suspends; nearest `Suspense` shows its `fallback` | | Loading UI | none unless you build one elsewhere | the `fallback` you pass to `Suspense` | | Errors | `onError`, or rethrown during render | thrown to the nearest error boundary | | `onError` prop | allowed | not allowed: the provider throws | | State of the open database | per provider instance | one cached promise at module scope | ## How the Suspense mode works In expo-sqlite 57, the Suspense branch obtains a promise that runs the same import, open and `onInit` steps, and passes it to React's `use()`. While the promise is pending, React suspends the provider and shows the `fallback` of the closest `Suspense` boundary above it. When it resolves, the provider renders its children with the database in context, exactly as in the default mode. What you have to add: - A **`Suspense` boundary** above the provider, with a `fallback` such as a skeleton of the sighting log. Without one, the suspension propagates to a boundary higher up, possibly one that blanks the whole app. - An **error boundary** above it for failures. A rejected promise, such as `onInit` throwing because a migration failed, is thrown during render and caught by the nearest error boundary. What you must not add: - **`onError`.** The provider checks for this combination first and throws `Cannot use onError with useSuspense, use error boundaries instead.` The two error paths are mutually exclusive by design. ## The module-level cache and its consequences To give `use()` a stable promise across re-renders, the Suspense path stores **one** cached entry at module scope: the promise, plus the `databaseName`, `directory`, `options` and `onInit` it was created for. On each render it compares the new props with that entry. `databaseName` and `directory` are strings, but `options` and `onInit` are compared **by identity**. If they match, the cached promise is reused. If anything differs, the source chains a new promise that **closes the cached database** and then opens the requested one. Two practical rules follow: 1. **Keep `options` and `onInit` stable.** Define them at module scope (or memoize them). A new identity on a re-render means close, reopen, rerun `onInit`, and a fresh suspension that flashes the fallback. 2. **Use one Suspense-mode provider per app.** A second one for a different file does not get its own cache entry; asking for it closes the first provider's database, which the first subtree is still using. For two databases, use the default mode for at least one of them, or open the second one yourself. ## When to choose it Choose `useSuspense` when the app already uses Suspense boundaries for loading states (for example with a router that renders layouts inside them) and you want the database wait to look like every other wait. Keep the default when you need `onError` for custom recovery, such as offering to reset a corrupted local database, or when you run more than one provider. ## What the fallback is really waiting for The fallback stays up for the whole setup, not just the file open: - copying a bundled `assetSource` file on first launch, which can take noticeable time for a large file; - every migration step in `onInit`, which on an old install may be several; - any seeding or cleanup you added to `onInit`. Keep `onInit` lean and idempotent, and measure it on a slow Android device after an upgrade from the oldest supported version, since that is the longest the user will stare at the fallback. The same work runs in the default mode too; there it is simply hidden behind `null` instead of a fallback. ## A worked shape for the bird log - Root layout: an error boundary, then `Suspense` with a skeleton list as `fallback`. - Inside: `SQLiteProvider databaseName="birds.db" onInit={migrate} useSuspense`, with `migrate` imported from a module. - Screens: call `useSQLiteContext()` as usual; they never see a loading state for the database.
- Why does expo-sqlite reject onError together with useSuspense?In Suspense mode the failure is a rejected promise passed to React's `use()`, which React throws during render to the nearest error boundary. A separate `onError` callback would be a second, conflicting error path, so the provider throws and tells you to use an error boundary.
- What happens if two SQLiteProviders with useSuspense open different database files?The Suspense path keeps one cached database promise at module scope. When the second provider asks for another file, the source closes the cached database before opening the new one, so the first subtree is left holding a closed database. Use the default mode for one of them.
saying these in an interview costs you the question
- useSuspense makes SQLiteProvider render its own loading fallback
- onError still works as a logger when useSuspense is set
- With useSuspense, open errors are swallowed and the fallback stays forever
- Each Suspense-mode provider caches its own database independently