Reactivity model
Every read is a MobX observable. Every write that goes through the reactive driver invalidates the tables it touches. React's use() hook ties the two together.
The observable graph
When you call orm.findAll(Account, …):
- The ORM compiles the query plan and builds a
Query<Account[]>instance. - Internally it subscribes to the tables it reads (default: every table that appears in the effective SQL; overridable with
watch: [...]). - It kicks off a
driver.all(sql, params)to fetch rows. Each row is wrapped inobservable.objectand plugged into the identity map. - The
Queryitself has astatusobservable ("pending" → "fulfilled" | "rejected") and avalueobservable (the row array). - Components
use()-ing the Query suspend; when status flips to"fulfilled", React resumes them.
On the next invalidation (another run/exec on the reactive driver that names one of the watched tables), the Query re-fetches, diffs the new rows against the old, and patches individual row observables in place. Components that only read the fields that actually changed re-render.
Field promises
declare class Account extends Entity {
declare name: Promise<string>;
declare transactions: Promise<Transaction[]>;
}Each field is a lazy Promise-like value:
- On first
await account.name/use(account.name), the ORM fires aSELECT name FROM accounts WHERE id = ?lookup (or hits the cache from_applyRowif an earlier query populated it). - The Promise is a
TrackedPromise—.status,.value,.reasonare all observables. React'suse()reads.status, suspending while"pending". - After the lookup,
account.namestays fulfilled forever. Subsequentuse(account.name)resolves synchronously.
When invalidation refetches the Account's row, _applyRow only swaps the _row observable if the underlying values changed. React re-renders only components that use()-ed fields whose values moved.
Query invalidation
Three things invalidate queries:
- Driver-detected mutations.
orm.driver.run("UPDATE transactions …")— the reactive wrapper parses the SQL, extracts mutated tables, and fires table-scoped notifications after the run resolves. - Transaction commits. Inside
orm.transaction(async () => { ... }), invalidations are deferred and flushed on COMMIT. ROLLBACK drops them. One refetch per table per transaction, not per statement. - Manual
orm.invalidate(table). For cases where the ORM's driver wrapper isn't the mutation path — see Integration with sql-git.
Why this is fast
- Positional + keyed diff.
SqlQuerydiffs new rows against old by position (default) or by akeyByfunction. Rows that didn't move retain their object identity, so components that read them don't re-render. - Deep patch. Nested objects (JSON columns, relations) are patched recursively. Only the leaf values that changed trigger observers.
- Stale-while-revalidate. Refetches don't clear the observable array first — the old rows stay visible while the new ones arrive, then are patched in place. No Suspense flash on mutation.
The whole write loop
user clicks "Add transaction"
↓
await orm.insert(Transaction, {...})
↓
orm.driver.run("INSERT INTO transactions …")
↓
reactive wrapper sees "INSERT INTO transactions"
↓
orm._notifyTable("transactions")
↓
→ _refreshCachedRowsFor("transactions") // patches entity rows in place
→ _refreshCachedRelationsFor("transactions") // re-runs hasMany/belongsTo SELECTs
→ subscribers on "transactions" (SqlQuery + Query) re-execute
↓
MobX notifies observers of changed values
↓
React re-renders components that `use()`-ed the changed fieldsNo extra bookkeeping. If you can describe the data change in terms of "which tables got written," the graph updates correctly.