In an Expo app, how do you make a bird-sighting log screen built on expo-sqlite update when a new sighting is saved on another screen?
answer
- queries return snapshots
- an open option, default false
- module-level listener function
- filter on tableName, then re-query
- remove() in the effect cleanup
basics
~10 sOpen the database with enableChangeListener: true, for example through SQLiteProvider's options, then subscribe with addDatabaseChangeListener and re-run the screen's query when an event names the sightings table. Remove the subscription on unmount.
solid answer
~40 s`getAllAsync` returns a snapshot; nothing in expo-sqlite re-runs it for you. To react to writes, open the database with `options={{ enableChangeListener: true }}` on `SQLiteProvider` (the default is `false`), which makes the native side install SQLite's update hook on that connection. Then, in the log screen's `useEffect`, call `addDatabaseChangeListener(listener)`; each event carries `databaseName`, `databaseFilePath`, `tableName` and `rowId`. When `tableName` is `sightings`, reload the list, and call `subscription.remove()` in the cleanup. If exactly one form writes and one list reads, a plain refresh callback or shared store is simpler; the listener earns its place when writes come from many places, including background work. ORM live-query hooks, such as the one Drizzle ORM offers for expo-sqlite, wrap this same listener and need the same flag.
code
tsx · 35 linesimport { addDatabaseChangeListener, useSQLiteContext } from 'expo-sqlite';
import { useEffect, useState } from 'react';
import { FlatList, Text } from 'react-native';
type Sighting = { id: number; species: string; seen_at: number };
export function SightingLog() {
const db = useSQLiteContext();
const [rows, setRows] = useState<Sighting[]>([]);
useEffect(() => {
const load = async () => {
setRows(
await db.getAllAsync<Sighting>(
'SELECT id, species, seen_at FROM sightings ORDER BY seen_at DESC LIMIT 200'
)
);
};
load();
const subscription = addDatabaseChangeListener(({ tableName }) => {
if (tableName === 'sightings') {
load();
}
});
return () => subscription.remove();
}, [db]);
return (
<FlatList
data={rows}
keyExtractor={(s) => String(s.id)}
renderItem={({ item }) => <Text>{item.species}</Text>}
/>
);
}go deeper
Recall that query results are snapshots, and that change events need enableChangeListener: true when opening plus addDatabaseChangeListener to subscribe.
Explain the wiring in a screen: subscribe in an effect, filter by tableName, re-query, and remove the subscription in the cleanup.
Decide between a listener and an explicit refresh, make sure every writer uses the flagged connection, and plan for per-row event volume on imports.
Choose a data-flow model for the app, database-driven live queries versus a store updated by writers, and apply it consistently across features.
## Why the list does not update by itself A screen that shows the bird log typically runs `db.getAllAsync('SELECT ... FROM sightings ...')` in an effect and stores the rows in state. That result is a **snapshot**: a plain array copied out of the database at that moment. When the "add sighting" form inserts a row, nothing tells the log screen, so it keeps showing the old array until something runs the query again. There are two ways to make it run again: - **Tell it explicitly.** The code that writes calls a refresh function, or updates a shared store the list reads from. - **Listen to the database.** The list subscribes to change notifications from expo-sqlite and reloads when its table changes. This is what "live queries" mean here. ## Turning on change notifications Change events are off by default. They are an **open option**: `enableChangeListener`, default `false`, part of `SQLiteOpenOptions`. With the provider, pass it through the `options` prop: ```tsx const DB_OPTIONS = { enableChangeListener: true }; <SQLiteProvider databaseName="birds.db" options={DB_OPTIONS} onInit={migrate}> ``` When it is set, expo-sqlite registers SQLite's **update hook** on that native connection. From then on, every row inserted, updated or deleted through that connection produces an `onDatabaseChange` event. ## Subscribing `addDatabaseChangeListener(listener)` is a **module-level function** exported by `expo-sqlite`, not a method on the database. It returns a subscription with `remove()`. The listener receives a `DatabaseChangeEvent`: | Field | Meaning | |---|---| | `databaseName` | the schema name inside the connection, `main` unless you `ATTACH` another | | `databaseFilePath` | absolute path of the database file | | `tableName` | the table whose row changed | | `rowId` | the rowid of the changed row | Because the function is module-wide, one listener hears events from **every** database opened with the flag, so filter on `tableName`, and on `databaseFilePath` if the app has several files. The pattern in a screen: 1. Load the rows once when the screen mounts. 2. Subscribe in the same effect; when `tableName === 'sightings'`, run the query again and set state. 3. Return a cleanup that calls `subscription.remove()`, so an unmounted screen stops reloading and repeated mounts do not stack listeners. ## What the event is, and is not The event says **that** a row changed, not **what** it now contains. There is no row data in it, and no SQL text. Treat it as "the sightings table is stale" and re-query; do not try to patch the list from the event. Other practical points: - **Every writer must use a flagged connection.** Changes made through a connection opened without `enableChangeListener` (for example, a second `openDatabaseAsync` call with different options) produce no events. Sharing the provider's database via `useSQLiteContext()` avoids that. - **It is per row.** A bulk import produces one event per row; coalesce reloads for that case. - **libSQL mode does not support it**; the iOS libSQL module throws if the flag is set. ## A checklist for the screen - The provider passes `options` with `enableChangeListener: true`, defined at module scope. - The screen loads once on mount, then subscribes in the same effect. - The listener filters on `tableName` (and `databaseFilePath` when there are several files). - The reload is a bounded query, for example the most recent 200 sightings, not the whole table. - The effect's cleanup calls `subscription.remove()`. - Writers all use the context database, not their own unflagged connection. ## Choosing between the listener and a direct refresh - **One writer, one reader, same flow**: after `runAsync` succeeds, navigate back and let the log reload on focus, or call a refresh from a shared store. No listener needed. - **Many writers**: a form, a background sync, a "delete all" in settings, an import. The listener catches all of them without each writer knowing who is watching. - **An ORM**: live-query hooks from ORMs that support expo-sqlite subscribe to this same listener for you and re-run the ORM query, which is why their setup also asks for `enableChangeListener`.
- Why not update the list directly from the change event instead of re-querying?The event carries only the database name, file path, table name and rowid, not the row's values or the kind of statement. Re-querying (or fetching that one rowid) is the only way to know the current data, and it also handles deletes and updates uniformly.
- When is a change listener overkill for this screen?When one form is the only writer and the log is the only reader. Reloading when the log regains focus, or a refresh call from a shared store, is simpler and does not need a per-row native hook. The listener pays off when writes come from many places.
It is like a doorbell on a shared pantry rather than a camera: the bell tells you someone opened the sightings shelf, not what they put there, so you walk over and look again.
saying these in an interview costs you the question
- useSQLiteContext re-renders the screen automatically when its table changes
- addDatabaseChangeListener works without setting enableChangeListener on open
- The change event contains the new row's column values
- The listener only hears changes to the database it was created from
- No cleanup is needed because listeners are tied to the component