Sit down to build a task list the normal way and, before you write a line of UI, you owe the machine a small bureaucracy. An API endpoint to list the tasks. A second to create one. Authentication to know who’s asking. Authorisation middleware to decide what they’re allowed to see. An ORM and a database behind that. A client-side cache so the screen doesn’t flicker, and a pile of code to keep that cache honest when the data changes. Only then do you get to render a list.
In xNet you write this instead — and you’re done:
function Tasks() {
const { data: tasks, loading } = useQuery(TaskSchema, {
where: { status: 'todo' },
orderBy: { createdAt: 'desc' }
})
const { create } = useMutate()
// tasks is live, local and already authorised — no endpoint, no fetch
if (loading) return <Spinner />
return <TaskList items={tasks}
onCreate={(title) => create(TaskSchema, { title, status: 'todo' })} />
}
That’s the whole feature. tasks is live: edit one on
your phone and this list updates on your laptop. It’s local: it
reads from a database a few millimetres away, so it’s instant and
works on a plane. It’s authorised: a user who isn’t allowed
to see a task never receives it. And you wired up none of that.
This essay is a tour of the hooks on the surface and a dive into
everything that one useQuery quietly stands on — a signed
change log, a real SQL database running in a background thread, a
scheduler, a sync engine. It’s the tip of an iceberg, and the point
of the tour is that you’re allowed to look under the waterline.
There’s code along the way; every grey panel is skippable and the
story still holds — but being able to show you the machine is half the
argument.
One: there is no API
Start with the thing that’s missing. In the usual architecture there is a tier in the middle — the endpoints, the request handlers, the serialisers — whose entire job is to stand between your component and the data and mediate every conversation. You design it, version it, secure it, deploy it, and pay for it to be awake at 3am.
xNet deletes that tier. The hook is the interface to your data. Your “API design” becomes a much smaller, friendlier thing: which schemas exist, and which hooks read and write them. Both live in your client, in TypeScript, fully typed.
flowchart LR
subgraph STACK["The usual way — a server tier in the middle"]
direction LR
A1["Component"] --> A2["fetch /<br/>GraphQL"]
A2 -->|"network"| A3["API<br/>endpoint"]
A3 --> A4["Auth<br/>middleware"]
A4 --> A5["ORM"]
A5 --> A6["Database<br/>(theirs)"]
end
subgraph XN["xNet — the hook is the API"]
direction LR
B1["Component"] --> B2["useQuery(TaskSchema)"]
B2 --> B3["SQLite on<br/>your device"]
B3 -.->|"later, optional"| B4["Hub (relay)"]
end It sounds reckless the first time you hear it — the client talks straight to the database? — and it would be, on the old web, where “trust the client” is a punchline. The rest of this piece is really one long answer to why it’s safe here. But first, the tour.
Two: a tour of the hooks
There are only a handful, and they read like the data tools you already
know. useQuery reads. Hand it a schema for the whole set, a
schema and an id for one, or a schema and a filter for a slice —
where, orderBy, limit, and for the
ambitious, full-text search and spatial windows
for canvases. What comes back is already live; you don’t poll and
you don’t re-fetch.
useMutate writes — create, update,
remove, or several at once in a single atomic
mutate. The change lands in your local database first, so
the UI updates on the same tick; the network catches up afterwards, if
it’s there at all. useNode is for editing one thing
deeply: it hands you the node and a collaborative document with
live cursors and presence, for when two people are in the same paragraph.
And useInfiniteQuery grows a window rather than freezing
pages — load more and the earlier rows stay live instead of going stale.
Notice what the surface snippet didn’t contain: no fetch,
no query keys to invalidate, no loading spinner plumbing beyond a single
boolean, no websocket. The ergonomics are deliberately the ones a React
developer already has in their fingers — the backend behind them is just
no longer someone else’s server.
Three: the schema is the API — authorisation included
If the hooks are how you read and write, the schema is where you say what a thing is and who may touch it — and both halves live in the same small object. Here is a task whose editors are named in a property, whose owner is whoever created it, and whose rules are stated right next to its fields.
Open the panel — a schema with its authorisation
export const TaskSchema = defineSchema({
name: 'Task', namespace: 'xnet://xnet.fyi/',
properties: { title: text({ required: true }), status: select({ options: STATUSES }) },
authorization: {
roles: { owner: role.creator(), editor: role.property('editors') },
actions: {
read: allow('editor', 'owner'),
write: allow('editor', 'owner'),
delete: allow('owner')
}
}
})
This is worth dwelling on, because it’s where most systems quietly
split in two. Normally your client has an opinion about
permissions — it greys out a button — and the server holds the
truth, re-checking everything because the client can’t be
trusted. Two implementations of the same rules, forever drifting apart.
In xNet there is one declaration. It’s what greys out the button
and it’s what’s enforced. When you can’t work out why
something was allowed or denied, useAuthTrace will hand you
the roles, the grants, and the reasons it decided the way it did.
Which raises the obvious objection, and it deserves a straight answer.
Four: why you can trust a rule you wrote on the client
The old web’s iron law is “never trust the client,” and it’s correct there, because a rule that only runs on a device the attacker controls is a suggestion. xNet doesn’t trust the client either. What it trusts is cryptography, and that changes where the rules can safely live.
Every write becomes a small, signed record — a change — sealed with a key only you hold (Ed25519) and fingerprinted so the whole history forms a tamper-evident chain (BLAKE3). Your identity isn’t an account some company can switch off; it’s a key pair, and your public name is literally your public key. (The companion essay, The Loom You Can Read, follows a single change through that machinery in detail.) The consequence that matters here: any device can check, on its own, that a change really came from who it claims and wasn’t altered — and can run the schema’s rules against it — before letting it touch the database.
Open the panel — the gate every change passes through
// runs before any incoming change touches your database
if (!verifyChangeHash(change)) reject('INVALID_HASH') // recompute BLAKE3, compare
if (!verifySignature(change)) reject('BAD_SIGNATURE') // Ed25519, key from the DID
const decision = await evaluator.check(authorDID, change, 'write')
if (!decision.allowed) reject(decision.reasons) // the rules you declared, enforced sequenceDiagram participant U as Your device participant Hub as Hub (relay, low trust) participant P as Collaborator U->>U: useMutate update — becomes a signed, hash-chained change U->>U: write to local SQLite — your screen updates instantly U->>Hub: send the signed change Hub->>Hub: verify signature, recompute hash (INVALID_HASH guard) Hub-->>P: relay it — cannot forge it, cannot rewrite the chain P->>P: verify, authorise against the schema, then merge (last-write-wins) P->>P: the live query deltas the new value into view
So “specify your whole API in the client” isn’t a shortcut that trades away safety. The authority moved from a privileged place in the network to a property of the data. A rule attached to a signed, verifiable object is enforceable anywhere that object travels.
Five: how a view materialises
Back to that live list. When you call useQuery, it
doesn’t hand React an array; it hands React a subscription
— a way to read the current answer synchronously, and a way to be told
when the answer changes. That’s the exact shape React 18’s
useSyncExternalStore wants, which is why the result stays
tear-free under concurrent rendering without any effort from you.
Open the panel — the contract behind a live query
interface QuerySubscription<P> {
getSnapshot(): NodeState[] | null // synchronous read from the cache
subscribe(cb: () => void): () => void // React calls this to stay live
}
// useQuery feeds exactly this to React.useSyncExternalStore — no more, no less Behind that interface, a cache keeps the materialised result. When data changes, the bridge doesn’t re-run your query from scratch; it keeps a small buffer of spare rows around the edge of your window and applies the change in place — slot a new row into its sorted position, update one, drop one — only falling back to a full reload when a burst of changes is too large to patch. Unchanged rows keep their identity, so the components rendering them simply don’t re-render.
sequenceDiagram participant C as Component participant H as useQuery participant Br as Bridge + cache participant W as Data worker participant S as SQLite worker C->>H: useQuery(TaskSchema, where status = todo) H->>Br: subscribe(descriptor) Br->>W: load (first time only) W->>S: SELECT … (off the main thread) S-->>W: rows W-->>Br: snapshot Br-->>H: getSnapshot() H-->>C: render Note over Br,W: an edit lands (local, or just-synced from a peer) W-->>Br: bounded delta — small change in place, big burst reloads Br-->>H: notify H-->>C: re-render only the rows that changed
A precise word on “live,” because honesty is the house style.
Your queries are live with respect to your local database: any
change written there flows into the view, and that includes changes that
arrive from a peer and get merged in locally. A pushier mode — the hub
streaming results to you as they happen — is designed and named in the
code but not switched on yet. When it is, it’ll slot in behind this
same subscription, and your useQuery won’t change a
character.
Six: the layer beneath the waterline
Now the dive. “Reads from a local database” is doing a lot of quiet work in the sentences above. That database is real SQLite — the same engine in your phone — compiled to WebAssembly and run inside a Web Worker, off the thread that paints your UI. It persists to a private corner of the browser called the Origin Private File System, so your data is still there tomorrow.
Open the panel — a real database, tuned, on a background thread
// inside a Web Worker — never on the thread that paints your UI
this.poolUtil = await sqlite3.installOpfsSAHPoolVfs({ name: 'opfs-sahpool' })
this.db = new this.poolUtil.OpfsSAHPoolDb(dbPath, 'c')
this.execSync('PRAGMA cache_size = -262144') // 256 MB page cache
this.execSync('PRAGMA mmap_size = 268435456') // fault pages via the OS
this.execSync('PRAGMA journal_mode = TRUNCATE') // fastest durable mode on OPFS There are actually two workers, and they talk to each other directly rather than bouncing every message through your UI thread: one owns the store and the live subscriptions, the other owns SQLite. Signing and verifying happen out here too, so the cryptography never janks a scroll. A single database connection can only do one thing at a time, so a small scheduler decides the order — and it always lets an interactive read jump ahead of a background import.
Open the panel — why a tap never waits behind an import
const LANE_ORDER = ['interactive', 'bulk', 'write'] as const
// drained in that order, so a tap never waits behind a bulk import;
// and two identical reads in flight coalesce into a single execution. flowchart TB
subgraph MAIN["Main thread — your React app"]
UI["Components + hooks"] --> BR["Worker bridge (Comlink)"]
end
subgraph DW["Data worker"]
NS["Store + live query subscriptions"]
SG["sign / verify — off the UI thread"]
end
subgraph SW["SQLite worker"]
SCH["Priority scheduler<br/>interactive → bulk → write"]
DB["SQLite (WebAssembly)"]
end
BR <-->|"RPC"| NS
NS <-->|"direct MessagePort"| SCH
SCH --> DB
DB --> OPFS[("OPFS on your disk<br/>with graceful fallbacks")]
And here’s the part that earns the phrase it just works:
the database layer probes what the device can actually do and adapts. On
a modern browser it takes fast, exclusive file handles. On an older one,
or a locked-down webview, it falls back to a slower-but-durable mode, and
only as a last resort to an in-memory database — loudly, so it’s
never a silent surprise. The same useQuery runs on a
flagship laptop and a three-year-old phone; the layer underneath quietly
picks the best engine each one can offer.
Seven: local first, mirrored to remote
Because the real copy is on your device, a query is just a query against your own SQLite — instant, offline, no permission to ask. For most workspaces that’s the whole story; everything you own fits happily on the device. When a dataset is large, or a request is one only the fleet can answer — a full-text search across more than you hold, say — a router decides to involve the hub, and can serve the local answer immediately while a fuller one refreshes in from the network behind it.
New data doesn’t arrive as a refetch. A peer’s signed change comes in, gets verified and authorised, and merges into your local database by a simple, deterministic rule — last edit wins, with a logical clock and the author’s key breaking ties, so every device reaches the same answer with no server casting a deciding vote. The moment it lands locally, the live query from section five deltas it into your view. “Mirrored against a remote database and merging in new data as needed” isn’t a feature bolted on top; it’s what falls out of a signed change log meeting a live local query.
It just works
Come back up to the surface and look again at the dozen lines we started with. Here is an incomplete list of what you did not write to make them true: an API endpoint, request handlers, an authentication layer, authorisation middleware, an ORM, a database migration runner, a client-side cache, the logic to invalidate it, a websocket, an offline queue, a background sync worker, a conflict resolver, and a connection pool. They’re all present. You just didn’t have to assemble them, and — this is the part that matters — you’re allowed to open every one.
It’s worth being clear about the company xNet keeps, because it isn’t alone in wanting this. Several good projects give you reactive queries over a local store; most of them keep a server as the root of trust.
| Approach | Database of record | Identity is… | Rules enforced by… | Offline writes |
|---|---|---|---|---|
| TanStack Query / SWR | none (a remote cache) | the app’s server | the server | No |
| Convex / InstantDB | a vendor cloud | a vendor account | the vendor backend | Partial |
| Replicache / Zero | your server | your server’s auth | the server | Yes (optimistic) |
| ElectricSQL / PowerSync | a server Postgres | your server’s auth | the server / rules | Yes |
| xNet | local SQLite (yours) | a did:key you mint | signed, hash-chained changes | Yes, first-class |
The family resemblance is real, and the borrowed ergonomics are on
purpose — if you’ve used useQuery from one of these,
ours will feel like home. What’s distinctly xNet’s is the
corner you’ve been standing in this whole tour: the master copy is
on the edge device, the authority is a key in your hands, and the network
is a convenience layered on top — not the place your work is kept.
None of the machinery beneath the waterline is exotic. SQLite, a worker, a hash, a signature, a tiny merge rule, a subscription. It’s plain engineering, arranged so that the simple thing on top — a hook you can read in one sitting — is also the correct thing, and the openable thing. So open it. Use the app, then read the source the excerpts above point at. Build something on the hooks and watch your “whole API” turn out to be a couple of schemas and a component. The tip is small on purpose. The iceberg is yours to inspect, all the way down.
Sources
- The tradition this is built in: Kleppmann et al., “Local-first software: you own your data, in spite of the cloud” (Ink & Switch, 2019).
- The reactive-query relatives, each solving a nearby problem: Rocicorp Zero and Replicache, Convex, InstantDB, ElectricSQL, PowerSync, and WatermelonDB.
-
The browser primitives the dive relies on:
React
useSyncExternalStore, SQLite Wasm + OPFS persistence, and Comlink for worker RPC. - The machine itself, in full: the sync architecture, protocol spec, and identity docs — every claim above links back to real, readable source.
- Companion essay: The Loom You Can Read, which follows a single change through the kernel beneath these hooks.
Code excerpts are trimmed and lightly simplified for reading; the file paths point at the real, unabridged source. “Live” queries are live with respect to the local database (including changes synced in from peers); the hub-push streaming mode is designed but not yet enabled, and is noted as such above. All artwork here is original.