Dexie collections provide seamless integration between TanStack DB and Dexie.js, enabling a local-first persistence layer with efficient change monitoring and IndexedDB storage. This integration keeps an in-memory TanStack DB collection in perfect sync with a Dexie table while supporting optimistic updates, efficient syncing, and reactive live updates.
The tanstack-dexie-db-collection package allows you to create collections that:
- Automatically sync with IndexedDB through Dexie.js for offline-first persistence
- Reactively update when Dexie data changes using
liveQuery - Support optimistic mutations with acknowledgment tracking
- Handle efficient initial syncing with configurable batch sizes
- Provide utilities for testing and advanced integration scenarios
- Use metadata fields for efficient sync and conflict resolution
npm install tanstack-dexie-db-collection @tanstack/react-dbCreate a TanStack DB collection that automatically syncs with a Dexie table:
import { createCollection } from "@tanstack/react-db"
import { dexieCollectionOptions } from "tanstack-dexie-db-collection"
import { z } from "zod"
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
getKey: (item) => item.id,
})
)This creates a collection that:
- Uses IndexedDB for persistence via Dexie
- Automatically creates a database named
app-dbwith a table namedtodos - Keeps the in-memory collection and Dexie table in sync
- Supports all standard TanStack DB operations
The dexieCollectionOptions function accepts the following options:
getKey: Function to extract the unique key from an item
id: Unique identifier for the collection (also used as table name if not specified)dbName: Dexie database name (default:'app-db')tableName/storeName: Name of the Dexie table (defaults to the collectionid)
schema: Schema for type inference and optional runtime validation (Standard Schema v1, Zod, etc.)
syncBatchSize: Batch size for initial sync (default:1000)rowUpdateMode: Update strategy -'partial'or'full'(default:'partial')
ackTimeoutMs: Timeout for acknowledgment tracking (default:2000ms)awaitTimeoutMs: Timeout for awaiting IDs (default:10000ms)
codec: Optional data transformation between stored and in-memory shapes
Provide a schema for type inference and validation:
import { z } from "zod"
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
createdAt: z.date().optional(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
getKey: (item) => item.id, // `item` is fully typed from schema
dbName: "my-todo-app",
tableName: "todos",
})
)When using a schema:
- The
getKeyfunction is fully typed - Runtime validation is automatically applied
- Type inference works seamlessly
import { z } from "zod"
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
dbName: "my-app-db",
tableName: "user_todos",
getKey: (item) => item.id,
})
)Control how updates are applied to Dexie:
import { z } from "zod"
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
getKey: (item) => item.id,
rowUpdateMode: "full", // Use table.put for full replacement
// rowUpdateMode: 'partial', // Use table.update for partial updates (default)
})
)Transform data between stored and in-memory formats:
import { z } from "zod"
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
createdAt: z.date().optional(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
getKey: (item) => item.id,
codec: {
// Transform when reading from Dexie
parse: (stored) => ({
...stored,
createdAt: stored.createdAt ? new Date(stored.createdAt) : undefined,
}),
// Transform when writing to Dexie
serialize: (item) => ({
...item,
createdAt: item.createdAt?.toISOString(),
}),
},
})
)Configure sync behavior for large datasets:
import { z } from "zod"
const todoSchema = z.object({
id: z.string(),
text: z.string(),
completed: z.boolean(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
getKey: (item) => item.id,
syncBatchSize: 500, // Smaller batches for memory optimization
ackTimeoutMs: 5000, // Longer timeout for slow devices
awaitTimeoutMs: 15000, // Extended timeout for tests
})
)- Batch Loading: Reads Dexie rows in batches for efficient transfer
- Live Updates: Uses Dexie's
liveQueryfor real-time reactive updates - Change Detection: Efficient diffing prevents unnecessary updates
The collection provides utility methods via collection.utils:
// Get direct access to the Dexie table
const table = todosCollection.utils.getTable()
await table.where("completed").equals(true).toArray()// Force the liveQuery to re-evaluate
todosCollection.utils.refresh()
// Trigger refresh and wait for processing
await todosCollection.utils.refetch()For collections that need sequential numeric IDs (1, 2, 3...) instead of UUIDs, use the getNextId() utility:
import { z } from "zod"
// Schema with numeric ID
const todoSchema = z.object({
id: z.number(),
text: z.string(),
completed: z.boolean(),
})
const todosCollection = createCollection(
dexieCollectionOptions({
id: "todos",
schema: todoSchema,
getKey: (item) => item.id,
})
)
// Generate sequential IDs
async function addTodo(text: string) {
const nextId = await todosCollection.utils.getNextId()
const tx = todosCollection.insert({
id: nextId, // 1, 2, 3, 4...
text,
completed: false,
})
await tx.isPersisted.promise
}
// Usage
await addTodo("Buy milk") // Creates todo with id: 1
await addTodo("Walk dog") // Creates todo with id: 2
await addTodo("Write code") // Creates todo with id: 3Features:
- Auto-initializes from max existing ID on first use
- Thread-safe across browser tabs via Dexie transactions
- Counter never decreases (deletions create gaps, which is normal)
- Stored internally as a special record (filtered from queries)
Example with bootstrap:
// Bootstrap from server
const serverTodos = await fetch("/api/todos").then((r) => r.json())
await todosCollection.utils.bulkInsertLocally(serverTodos)
// If server has IDs 1-100, counter initializes to 100
// Create new todo
const nextId = await todosCollection.utils.getNextId() // Returns 101This repo includes copy-paste-ready examples for persistence handlers and backend sync in EXAMPLES.md (project root). See that file for complete snippets. Minimal summary:
- Handlers receive
{ transaction }. Usetransaction.mutations(array of{ type, key, modified, changes }) for your backend payloads. - Default is fire-and-forget. Set
awaitPersistence: trueto wait for the handler to complete before marking the operation persisted. - Errors are swallowed by default; toggle
swallowPersistenceErrorsto change this behavior. UsepersistenceTimeoutMsto bound wait time. - Handlers run only after local Dexie writes succeed.
Dexie collections work seamlessly with live queries for reactive data access. You can create filtered, sorted views that automatically update when the underlying data changes.
import {
createCollection,
liveQueryCollectionOptions,
eq,
} from "@tanstack/react-db"
import { dexieCollectionOptions } from "tanstack-dexie-db-collection"
import { z } from "zod"
const noteSchema = z.object({
id: z.string(),
title: z.string(),
content: z.string(),
isPinned: z.boolean(),
updatedAt: z.date(),
})
// Base collection with Dexie persistence
const notesCollection = createCollection(
dexieCollectionOptions({
id: "notes",
schema: noteSchema,
getKey: (note) => note.id,
})
)
// Live query for pinned notes
export const pinnedNotesCollection = createCollection(
liveQueryCollectionOptions({
id: "pinned-notes-live",
startSync: true,
query: (q) =>
q
.from({ note: notesCollection })
.where(({ note }) => eq(note.isPinned, true))
.orderBy(({ note }) => note.updatedAt, "desc"),
})
)
// Use in React component
function PinnedNotes() {
const { data: pinnedNotes } = useLiveQuery(pinnedNotesCollection)
return (
<div>
{pinnedNotes.map((note) => (
<div key={note.id}>{note.title}</div>
))}
</div>
)
}import {
createCollection,
liveQueryCollectionOptions,
eq,
gt,
} from "@tanstack/react-db"
import { z } from "zod"
const userSchema = z.object({
id: z.string(),
name: z.string(),
isActive: z.boolean(),
})
const taskSchema = z.object({
id: z.string(),
title: z.string(),
userId: z.string(),
priority: z.number(),
dueDate: z.date(),
})
// Base collections
const usersCollection = createCollection(
dexieCollectionOptions({
id: "users",
schema: userSchema,
getKey: (user) => user.id,
})
)
const tasksCollection = createCollection(
dexieCollectionOptions({
id: "tasks",
schema: taskSchema,
getKey: (task) => task.id,
})
)
// Live query for active user tasks
export const activeUserTasksCollection = createCollection(
liveQueryCollectionOptions({
id: "active-user-tasks",
startSync: true,
query: (q) =>
q
.from({ user: usersCollection })
.join({ task: tasksCollection }, ({ user, task }) =>
eq(user.id, task.userId)
)
.where(({ user }) => eq(user.isActive, true))
.where(({ task }) => gt(task.priority, 2))
.select(({ user, task }) => ({
taskId: task.id,
taskTitle: task.title,
userName: user.name,
priority: task.priority,
dueDate: task.dueDate,
}))
.orderBy(({ task }) => task.dueDate, "asc"),
})
)
// Use in component
function HighPriorityTasks() {
const { data: tasks } = useLiveQuery(activeUserTasksCollection)
return (
<div>
<h2>High Priority Tasks</h2>
{tasks.map((task) => (
<div key={task.taskId}>
<h3>{task.taskTitle}</h3>
<p>Assigned to: {task.userName}</p>
<p>Priority: {task.priority}</p>
<p>Due: {task.dueDate}</p>
</div>
))}
</div>
)
}The live queries automatically update when:
- Notes are pinned/unpinned in the base collection
- Users' active status changes
- Tasks are added, modified, or their priority changes
- Due dates are updated
This provides a reactive, real-time UI that stays in sync with your IndexedDB data.