references/electric-adapter.md
references/electric-adapter.mdBrowse 8 files
8,590 bytes
Token encoding: o200k_base
Snapshot 09776a8
Electric Adapter Reference
Install
pnpm add @tanstack/electric-db-collection @tanstack/react-db
Required Config
import { createCollection } from '@tanstack/react-db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
const collection = createCollection(
electricCollectionOptions({
shapeOptions: { url: '/api/todos' },
getKey: (item) => item.id,
}),
)
shapeOptions-- ElectricSQL ShapeStream config;urlis the proxy URL to ElectricgetKey-- extracts unique key from each item
Optional Config
| Option | Default | Description |
|---|---|---|
id | (none) | Unique collection identifier |
schema | (none) | StandardSchema validator |
shapeOptions.params | (none) | Additional shape params (e.g. { table: 'todos' }) |
syncMode | eager | eager, on-demand, or progressive |
onInsert | (none) | Persistence handler; should return { txid } |
onUpdate | (none) | Persistence handler; should return { txid } |
onDelete | (none) | Persistence handler; should return { txid } |
Sync Modes
eagerloads the full shape before use.on-demandloads only the subsets requested by live queries.progressiveloads the requested subset first, then completes the full sync in the background.
With SQLite persistence, progressive resume keeps hydrated rows visible while the full Electric stream resumes. On-demand subset snapshots use a bounded refresh wait, so a stalled native long-poll refresh does not block loading forever.
Three Sync Strategies
1. Txid Return (Recommended)
Handler returns { txid }. Client waits for that txid in the Electric stream.
onInsert: async ({ transaction }) => {
const response = await api.todos.create(transaction.mutations[0].modified)
return { txid: response.txid }
},
2. awaitMatch (Custom Match)
Use when txids are unavailable. Import isChangeMessage to match on message content.
import { isChangeMessage } from "@tanstack/electric-db-collection"
onInsert: async ({ transaction, collection }) => {
const newItem = transaction.mutations[0].modified
await api.todos.create(newItem)
await collection.utils.awaitMatch(
(message) =>
isChangeMessage(message) &&
message.headers.operation === "insert" &&
message.value.text === newItem.text,
5000 // timeout ms, defaults to 3000
)
},
3. Simple Timeout (Prototyping)
onInsert: async ({ transaction }) => {
await api.todos.create(transaction.mutations[0].modified)
await new Promise((resolve) => setTimeout(resolve, 2000))
},
Utility Methods (collection.utils)
awaitTxId(txid, timeout?)-- wait for txid in Electric stream; default timeout 5sawaitMatch(matchFn, timeout?)-- wait for message matching predicate; default timeout 3000ms
Helper Exports
import {
isChangeMessage,
isControlMessage,
} from '@tanstack/electric-db-collection'
// isChangeMessage(msg) -- true for insert/update/delete
// isControlMessage(msg) -- true for up-to-date/must-refetch
generateTxId Backend Pattern
The txid must be queried inside the same Postgres transaction as the mutation.
async function generateTxId(tx: any): Promise<number> {
const result = await tx`SELECT pg_current_xact_id()::xid::text as txid`
const txid = result[0]?.txid
if (txid === undefined) throw new Error('Failed to get transaction ID')
return parseInt(txid, 10)
}
async function createTodo(data) {
let txid!: number
const result = await sql.begin(async (tx) => {
txid = await generateTxId(tx) // INSIDE the transaction
const [todo] = await tx`INSERT INTO todos ${tx(data)} RETURNING *`
return todo
})
return { todo: result, txid }
}
Querying txid outside the transaction produces a mismatched txid -- awaitTxId stalls indefinitely.
Schema vs Parser: Two Separate Paths
When using Electric with a schema, data enters the collection via two independent paths:
- Sync path — Electric's
ShapeStreamapplies theparserfromshapeOptions. The schema is NOT applied to synced data. - Mutation path —
insert()andupdate()run through the collection schema. The parser is not involved.
For types that need transformation (e.g., timestamptz), you need BOTH configured:
const todosCollection = createCollection(
electricCollectionOptions({
schema: z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(), // Electric auto-parses bools
created_at: z.coerce.date(), // mutation path: coerce string → Date
}),
shapeOptions: {
url: '/api/todos',
parser: {
timestamptz: (value: string) => new Date(value), // sync path: parse incoming strings
},
},
getKey: (item) => item.id,
}),
)
Postgres → Electric type handling
| PG type | Electric auto-parses? | Schema needed? | Parser needed? |
|---|---|---|---|
text, uuid | Yes (string) | z.string() | No |
int4, int8 | Yes (number) | z.number() | No |
bool | Yes (boolean) | z.boolean() | No |
timestamptz | No (stays string) | z.coerce.date() | Yes — parser: { timestamptz: (v) => new Date(v) } |
jsonb | Yes (parsed object) | As needed | No |
Note: z.coerce.date() is Zod-specific. Other StandardSchema libraries have their own coercion patterns.
Proxy Route
Electric collections connect to a proxy URL (shapeOptions.url), not directly to Electric. Your app server must forward shape requests to Electric, passing through the Electric protocol query params.
The proxy route must:
- Accept GET requests at the URL you specify in
shapeOptions.url - Forward all query parameters (these are Electric protocol params like
offset,handle,live, etc.) - Proxy the response (SSE stream) back to the client
- Optionally add authentication headers or filter params
Implementation depends on your framework — use createServerFn in TanStack Start, API routes in Next.js, loader in Remix, etc. See the @electric-sql/client skills for proxy route examples:
npx @electric-sql/client intent list
Electric Client Skills
For deeper Electric-specific guidance (ShapeStream config, shape filtering, etc.), load the Electric client's built-in skills:
npx @electric-sql/client intent list
Debug Logging
localStorage.debug = 'ts/db:electric'
Complete Example
Always use a schema — types are inferred automatically, avoiding generic placement confusion.
import { createCollection } from '@tanstack/react-db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
import { z } from 'zod'
const todoSchema = z.object({
id: z.string(),
text: z.string().min(1),
completed: z.boolean(),
created_at: z.coerce.date(),
})
const todosCollection = createCollection(
electricCollectionOptions({
id: 'todos',
schema: todoSchema,
getKey: (item) => item.id,
shapeOptions: {
url: '/api/todos',
params: { table: 'todos' },
parser: {
timestamptz: (value: string) => new Date(value), // sync path
},
},
onInsert: async ({ transaction }) => {
const response = await api.todos.create(transaction.mutations[0].modified)
return { txid: response.txid }
},
onUpdate: async ({ transaction }) => {
const { original, changes } = transaction.mutations[0]
const response = await api.todos.update({
where: { id: original.id },
data: changes,
})
return { txid: response.txid }
},
onDelete: async ({ transaction }) => {
const response = await api.todos.delete(transaction.mutations[0].key)
return { txid: response.txid }
},
}),
)Referenced from SKILL.md
Source excerpt starting at line 192.SKILL.mdView in source ↗192The returned `txid` tells the collection to hold optimistic state until Electric streams back that transaction. See the [Electric adapter reference](references/electric-adapter.md) for the full dual-path pattern (schema + parser).
Source excerpt starting at line 455.455- [TanStack Query adapter](references/query-adapter.md)456- [ElectricSQL adapter](references/electric-adapter.md)457- [PowerSync adapter](references/powersync-adapter.md)