In an Expo app, screens under an expo-sqlite SQLiteProvider go blank and lose their state whenever the parent re-renders; what is the likely cause and fix?
answer
- props compared by identity
- an inline arrow is new each render
- effect cleanup closes the database
- children unmount, onInit reruns
- hoist or memoize the props
basics
~20 sAn inline onInit arrow gives SQLiteProvider a new prop on every parent render; the provider then closes the database, renders null, reopens it and reruns onInit, remounting every child. Define onInit and options at module scope or memoize them.
solid answer
~40 s`SQLiteProvider` is wrapped in `memo` with a custom comparison: `databaseName`, `directory`, `onInit`, `onError` and `useSuspense` by identity, `options` and `assetSource` deep-compared. Its setup effect depends on `databaseName`, `directory`, `options` and `onInit` by identity. An inline `onInit={async (db) => migrate(db)}` is a new function each render, so memo lets the provider re-render and the effect restarts: the cleanup calls `closeAsync()`, sets loading back to true, and the provider renders `null`, unmounting every screen below it and discarding their state. Then it reopens the database, reruns `onInit`, and mounts fresh children. An inline `options` object alone survives memo, but once anything else forces a re-render, its new identity restarts the effect too. Fix: define `onInit`, `onError` and `options` at module scope, or wrap closures in `useCallback`/`useMemo`, and mount the provider in a component that rarely re-renders.
code
tsx · 20 linesimport { SQLiteProvider, type SQLiteDatabase } from 'expo-sqlite';
import type { ReactNode } from 'react';
// Stable identities: created once when the module loads.
const DB_OPTIONS = { enableChangeListener: true };
async function onInit(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)'
);
}
export function BirdDatabase({ children }: { children: ReactNode }) {
// Bad: onInit={async (db) => onInit(db)} would be a new function on every render.
return (
<SQLiteProvider databaseName="birds.db" options={DB_OPTIONS} onInit={onInit}>
{children}
</SQLiteProvider>
);
}go deeper
Recall that SQLiteProvider's onInit and options should be defined once, outside the component, not as inline arrow functions or objects.
Explain how memo's comparison and the setup effect's dependencies turn a new onInit identity into close, render null, reopen and remount.
Diagnose from symptoms, repeated onInit logs and remounting screens, and fix by hoisting or memoizing props and moving fast-changing state away from the provider.
Set conventions for app-root providers: stable configuration at module scope and deliberate triggers for anything that should reopen a resource.
## The symptom A bird-log app works, until a small change in the root component: a theme toggle, a network-status banner, anything that makes the parent of `SQLiteProvider` re-render. After that, every such re-render makes the whole app below the provider flash blank. Text typed into the sighting form disappears, scroll positions reset, and logs show migrations running again and again. Nothing is wrong with SQLite; the provider is closing and reopening the database. ## How the provider decides to reopen Two layers in expo-sqlite 57's `SQLiteProvider` matter. **The `memo` comparison.** The exported component is wrapped in `React.memo` with a custom equality function. It returns "equal" only when: - `databaseName`, `directory`, `onInit`, `onError` and `useSuspense` are **strictly equal** (`===`); - `options` and `assetSource` are **deep-equal**. If that function says "equal", the provider does not re-render at all. **The setup effect.** In the default (non-Suspense) mode, opening happens in a `useEffect` whose dependency list is `[databaseName, directory, options, onInit]`, compared by React's usual identity rule. When the effect re-runs, its cleanup: 1. calls `closeAsync()` on the open database; 2. clears the stored database and sets `loading` back to `true`; and the component then renders `null` until the new setup has opened the file and awaited `onInit` again. ## Tracing the bug | Written as | Parent re-renders | Result | |---|---|---| | `onInit={migrate}` (module function) | memo equal | nothing happens | | `onInit={async (db) => migrate(db)}` inline | memo sees a new `onInit` | re-render, effect restarts, database reopened | | `options={{ enableChangeListener: true }}` inline, all else stable | memo deep-compares, equal | nothing happens | | inline `options` plus an inline `onError` | memo sees a new `onError`; effect sees a new `options` | database reopened | When the provider returns `null`, React **unmounts** everything below it. Screens lose local state, navigation state held below the provider is reset, and when the database is back they mount from scratch. `onInit` also runs again each time, so migration code, logging and any seeding re-execute. In Suspense mode the path is different but the outcome is similar: the module-level cache compares `options` and `onInit` by identity, so a new identity closes the cached database, creates a new promise, and the Suspense fallback flashes. ## Fixing it - **Hoist.** Define `onInit`, `onError` and `options` at module scope. They rarely need anything from component state. - **Memoize** when they must close over something: `useCallback` for `onInit` and `onError`, `useMemo` for `options`, with honest dependency lists. - **Place the provider where re-renders are rare**, such as the root layout, and put fast-changing UI state (banners, themes) in components below it or beside it. - **Keep `databaseName` constant.** Changing it is a legitimate reason to close and reopen, for example when switching user accounts, but it should be a deliberate event. - **Do not reach for `useSuspense` as a fix.** It changes how the wait is shown, not whether it happens; unstable identities reopen the database in that mode too, and the fallback flashes instead of a blank screen. - **Lint for it.** A review rule, or a lint rule against inline functions on this component, stops the bug from returning when someone adds a quick closure. ## The same failure from a different direction A provider mounted **inside a screen** produces the same symptom by another route. When the user navigates away and the screen unmounts, the provider's cleanup closes the database; coming back mounts a new provider that reopens it and reruns `onInit`. Nothing about identities is wrong, but the lifetime is: the database lives only as long as that screen. The fix is the same principle, applied to placement: mount the provider once, at the app root or root layout, so its lifetime matches the app's and every screen shares the one open database. ## Confirming the diagnosis Useful checks before and after the fix: - Add a log line at the top of `onInit`; in a healthy app it prints once per launch. - Use React DevTools' highlight of re-renders, or the Profiler, to see whether the provider re-renders when the parent does. - Watch for screens remounting (an effect that logs on mount), which is the visible side of the provider returning `null`.
- Why doesn't an inline options object alone cause a reopen?The provider's `memo` comparison deep-compares `options`, so a new but equal object on a parent re-render is treated as unchanged and the provider does not re-render. The effect only sees the new identity if something else, such as an inline `onInit` or `onError`, lets the re-render through.
- When is closing and reopening the database the right behaviour?When the app really switches files, for example a per-account database after sign-out and sign-in. Changing `databaseName` then closes the old file and opens the new one. It should be a deliberate state change, not a side effect of an unrelated re-render.
saying these in an interview costs you the question
- SQLiteProvider opens the database once, whatever props change later
- Inline options objects always force a reopen on every render
- onInit runs only once per app launch no matter how the provider re-renders
- Remounting children under the provider keeps their local state