diff --git a/docs/collections/local-only-collection.md b/docs/collections/local-only-collection.md new file mode 100644 index 000000000..f7bde5f1f --- /dev/null +++ b/docs/collections/local-only-collection.md @@ -0,0 +1,311 @@ +--- +title: LocalOnly Collection +--- + +# LocalOnly Collection + +LocalOnly collections are designed for in-memory client data or UI state that doesn't need to persist across browser sessions or sync across tabs. + +## Overview + +The `localOnlyCollectionOptions` allows you to create collections that: +- Store data only in memory (no persistence) +- Support optimistic updates with automatic rollback on errors +- Provide optional initial data +- Work perfectly for temporary UI state and session-only data +- Automatically manage the transition from optimistic to confirmed state + +## Installation + +LocalOnly collections are included in the core TanStack DB package: + +```bash +npm install @tanstack/react-db +``` + +## Basic Usage + +```typescript +import { createCollection } from '@tanstack/react-db' +import { localOnlyCollectionOptions } from '@tanstack/react-db' + +const uiStateCollection = createCollection( + localOnlyCollectionOptions({ + id: 'ui-state', + getKey: (item) => item.id, + }) +) +``` + +## Configuration Options + +The `localOnlyCollectionOptions` function accepts the following options: + +### Required Options + +- `id`: Unique identifier for the collection +- `getKey`: Function to extract the unique key from an item + +### Optional Options + +- `schema`: [Standard Schema](https://standardschema.dev) compatible schema (e.g., Zod, Effect) for client-side validation +- `initialData`: Array of items to populate the collection with on creation +- `onInsert`: Optional handler function called before confirming inserts +- `onUpdate`: Optional handler function called before confirming updates +- `onDelete`: Optional handler function called before confirming deletes + +## Initial Data + +Populate the collection with initial data on creation: + +```typescript +const uiStateCollection = createCollection( + localOnlyCollectionOptions({ + id: 'ui-state', + getKey: (item) => item.id, + initialData: [ + { id: 'sidebar', isOpen: false }, + { id: 'theme', mode: 'light' }, + { id: 'modal', visible: false }, + ], + }) +) +``` + +## Mutation Handlers + +Mutation handlers are **completely optional**. When provided, they are called before the optimistic state is confirmed: + +```typescript +const tempDataCollection = createCollection( + localOnlyCollectionOptions({ + id: 'temp-data', + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + // Custom logic before confirming the insert + console.log('Inserting:', transaction.mutations[0].modified) + }, + onUpdate: async ({ transaction }) => { + // Custom logic before confirming the update + const { original, modified } = transaction.mutations[0] + console.log('Updating from', original, 'to', modified) + }, + onDelete: async ({ transaction }) => { + // Custom logic before confirming the delete + console.log('Deleting:', transaction.mutations[0].original) + }, + }) +) +``` + +## Manual Transactions + +When using LocalOnly collections with manual transactions (created via `createTransaction`), you must call `utils.acceptMutations()` to persist the changes: + +```typescript +import { createTransaction } from '@tanstack/react-db' + +const localData = createCollection( + localOnlyCollectionOptions({ + id: 'form-draft', + getKey: (item) => item.id, + }) +) + +const serverCollection = createCollection( + queryCollectionOptions({ + queryKey: ['items'], + queryFn: async () => api.items.getAll(), + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + await api.items.create(transaction.mutations[0].modified) + }, + }) +) + +const tx = createTransaction({ + mutationFn: async ({ transaction }) => { + // Handle server collection mutations explicitly in mutationFn + await Promise.all( + transaction.mutations + .filter((m) => m.collection === serverCollection) + .map((m) => api.items.create(m.modified)) + ) + + // After server mutations succeed, accept local collection mutations + localData.utils.acceptMutations(transaction) + }, +}) + +// Apply mutations to both collections in one transaction +tx.mutate(() => { + localData.insert({ id: 'draft-1', data: '...' }) + serverCollection.insert({ id: '1', name: 'Item' }) +}) + +await tx.commit() +``` + +## Complete Example: Modal State Management + +```typescript +import { createCollection } from '@tanstack/react-db' +import { localOnlyCollectionOptions } from '@tanstack/react-db' +import { useLiveQuery } from '@tanstack/react-db' +import { z } from 'zod' + +// Define schema +const modalStateSchema = z.object({ + id: z.string(), + isOpen: z.boolean(), + data: z.any().optional(), +}) + +type ModalState = z.infer + +// Create collection +export const modalStateCollection = createCollection( + localOnlyCollectionOptions({ + id: 'modal-state', + getKey: (item) => item.id, + schema: modalStateSchema, + initialData: [ + { id: 'user-profile', isOpen: false }, + { id: 'settings', isOpen: false }, + { id: 'confirm-delete', isOpen: false }, + ], + }) +) + +// Use in component +function UserProfileModal() { + const { data: modals } = useLiveQuery((q) => + q.from({ modal: modalStateCollection }) + .where(({ modal }) => modal.id === 'user-profile') + ) + + const modalState = modals[0] + + const openModal = (data?: any) => { + modalStateCollection.update('user-profile', (draft) => { + draft.isOpen = true + draft.data = data + }) + } + + const closeModal = () => { + modalStateCollection.update('user-profile', (draft) => { + draft.isOpen = false + draft.data = undefined + }) + } + + if (!modalState?.isOpen) return null + + return ( +
+

User Profile

+
{JSON.stringify(modalState.data, null, 2)}
+ +
+ ) +} +``` + +## Complete Example: Form Draft State + +```typescript +import { createCollection } from '@tanstack/react-db' +import { localOnlyCollectionOptions } from '@tanstack/react-db' +import { useLiveQuery } from '@tanstack/react-db' + +type FormDraft = { + id: string + formData: Record + lastModified: Date +} + +// Create collection for form drafts +export const formDraftsCollection = createCollection( + localOnlyCollectionOptions({ + id: 'form-drafts', + getKey: (item) => item.id, + }) +) + +// Use in component +function CreatePostForm() { + const { data: drafts } = useLiveQuery((q) => + q.from({ draft: formDraftsCollection }) + .where(({ draft }) => draft.id === 'new-post') + ) + + const currentDraft = drafts[0] + + const updateDraft = (field: string, value: any) => { + if (currentDraft) { + formDraftsCollection.update('new-post', (draft) => { + draft.formData[field] = value + draft.lastModified = new Date() + }) + } else { + formDraftsCollection.insert({ + id: 'new-post', + formData: { [field]: value }, + lastModified: new Date(), + }) + } + } + + const clearDraft = () => { + if (currentDraft) { + formDraftsCollection.delete('new-post') + } + } + + const submitForm = async () => { + if (!currentDraft) return + + await api.posts.create(currentDraft.formData) + clearDraft() + } + + return ( +
{ e.preventDefault(); submitForm() }}> + updateDraft('title', e.target.value)} + /> + + +
+ ) +} +``` + +## Use Cases + +LocalOnly collections are perfect for: +- Temporary UI state (modals, sidebars, tooltips) +- Form draft data during the current session +- Client-side computed or derived data +- Wizard/multi-step form state +- Temporary filters or search state +- In-memory caches + +## Comparison with LocalStorageCollection + +| Feature | LocalOnly | LocalStorage | +|---------|-----------|--------------| +| Persistence | None (in-memory only) | localStorage | +| Cross-tab sync | No | Yes | +| Survives page reload | No | Yes | +| Performance | Fastest | Fast | +| Size limits | Memory limits | ~5-10MB | +| Best for | Temporary UI state | User preferences | + +## Learn More + +- [Optimistic Mutations](../guides/mutations.md) +- [Live Queries](../guides/live-queries.md) +- [LocalStorage Collection](./local-storage-collection.md) diff --git a/docs/collections/local-storage-collection.md b/docs/collections/local-storage-collection.md new file mode 100644 index 000000000..a8dbeb20c --- /dev/null +++ b/docs/collections/local-storage-collection.md @@ -0,0 +1,299 @@ +--- +title: LocalStorage Collection +--- + +# LocalStorage Collection + +LocalStorage collections store small amounts of local-only state that persists across browser sessions and syncs across browser tabs in real-time. + +## Overview + +The `localStorageCollectionOptions` allows you to create collections that: +- Persist data to localStorage (or sessionStorage) +- Automatically sync across browser tabs using storage events +- Support optimistic updates with automatic rollback on errors +- Store all data under a single localStorage key +- Work with any storage API that matches the localStorage interface + +## Installation + +LocalStorage collections are included in the core TanStack DB package: + +```bash +npm install @tanstack/react-db +``` + +## Basic Usage + +```typescript +import { createCollection } from '@tanstack/react-db' +import { localStorageCollectionOptions } from '@tanstack/react-db' + +const userPreferencesCollection = createCollection( + localStorageCollectionOptions({ + id: 'user-preferences', + storageKey: 'app-user-prefs', + getKey: (item) => item.id, + }) +) +``` + +## Configuration Options + +The `localStorageCollectionOptions` function accepts the following options: + +### Required Options + +- `id`: Unique identifier for the collection +- `storageKey`: The localStorage key where all collection data is stored +- `getKey`: Function to extract the unique key from an item + +### Optional Options + +- `schema`: [Standard Schema](https://standardschema.dev) compatible schema (e.g., Zod, Effect) for client-side validation +- `storage`: Custom storage implementation (defaults to `localStorage`). Can be `sessionStorage` or any object with the localStorage API +- `storageEventApi`: Event API for subscribing to storage events (defaults to `window`). Enables custom cross-tab, cross-window, or cross-process synchronization +- `onInsert`: Optional handler function called when items are inserted +- `onUpdate`: Optional handler function called when items are updated +- `onDelete`: Optional handler function called when items are deleted + +## Cross-Tab Synchronization + +LocalStorage collections automatically sync across browser tabs in real-time: + +```typescript +const settingsCollection = createCollection( + localStorageCollectionOptions({ + id: 'settings', + storageKey: 'app-settings', + getKey: (item) => item.id, + }) +) + +// Changes in one tab are automatically reflected in all other tabs +// This works automatically via storage events +``` + +## Using SessionStorage + +You can use `sessionStorage` instead of `localStorage` for session-only persistence: + +```typescript +const sessionCollection = createCollection( + localStorageCollectionOptions({ + id: 'session-data', + storageKey: 'session-key', + storage: sessionStorage, // Use sessionStorage instead + getKey: (item) => item.id, + }) +) +``` + +## Custom Storage Backend + +Provide any storage implementation that matches the localStorage API: + +```typescript +// Example: Custom storage wrapper with encryption +const encryptedStorage = { + getItem(key: string) { + const encrypted = localStorage.getItem(key) + return encrypted ? decrypt(encrypted) : null + }, + setItem(key: string, value: string) { + localStorage.setItem(key, encrypt(value)) + }, + removeItem(key: string) { + localStorage.removeItem(key) + }, +} + +const secureCollection = createCollection( + localStorageCollectionOptions({ + id: 'secure-data', + storageKey: 'encrypted-key', + storage: encryptedStorage, + getKey: (item) => item.id, + }) +) +``` + +### Cross-Tab Sync with Custom Storage + +The `storageEventApi` option (defaults to `window`) allows the collection to subscribe to storage events for cross-tab synchronization. A custom storage implementation can provide this API to enable custom cross-tab, cross-window, or cross-process sync: + +```typescript +// Example: Custom storage event API for cross-process sync +const customStorageEventApi = { + addEventListener(event: string, handler: (e: StorageEvent) => void) { + // Custom event subscription logic + // Could be IPC, WebSocket, or any other mechanism + myCustomEventBus.on('storage-change', handler) + }, + removeEventListener(event: string, handler: (e: StorageEvent) => void) { + myCustomEventBus.off('storage-change', handler) + }, +} + +const syncedCollection = createCollection( + localStorageCollectionOptions({ + id: 'synced-data', + storageKey: 'data-key', + storage: customStorage, + storageEventApi: customStorageEventApi, // Custom event API + getKey: (item) => item.id, + }) +) +``` + +This enables synchronization across different contexts beyond just browser tabs, such as: +- Cross-process communication in Electron apps +- WebSocket-based sync across multiple browser windows +- Custom IPC mechanisms in desktop applications + +## Mutation Handlers + +Mutation handlers are **completely optional**. Data will persist to localStorage whether or not you provide handlers: + +```typescript +const preferencesCollection = createCollection( + localStorageCollectionOptions({ + id: 'preferences', + storageKey: 'user-prefs', + getKey: (item) => item.id, + // Optional: Add custom logic when preferences are updated + onUpdate: async ({ transaction }) => { + const { modified } = transaction.mutations[0] + console.log('Preference updated:', modified) + // Maybe send analytics or trigger other side effects + }, + }) +) +``` + +## Manual Transactions + +When using LocalStorage collections with manual transactions (created via `createTransaction`), you must call `utils.acceptMutations()` to persist the changes: + +```typescript +import { createTransaction } from '@tanstack/react-db' + +const localData = createCollection( + localStorageCollectionOptions({ + id: 'form-draft', + storageKey: 'draft-data', + getKey: (item) => item.id, + }) +) + +const serverCollection = createCollection( + queryCollectionOptions({ + queryKey: ['items'], + queryFn: async () => api.items.getAll(), + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + await api.items.create(transaction.mutations[0].modified) + }, + }) +) + +const tx = createTransaction({ + mutationFn: async ({ transaction }) => { + // Handle server collection mutations explicitly in mutationFn + await Promise.all( + transaction.mutations + .filter((m) => m.collection === serverCollection) + .map((m) => api.items.create(m.modified)) + ) + + // After server mutations succeed, persist local collection mutations + localData.utils.acceptMutations(transaction) + }, +}) + +// Apply mutations to both collections in one transaction +tx.mutate(() => { + localData.insert({ id: 'draft-1', data: '...' }) + serverCollection.insert({ id: '1', name: 'Item' }) +}) + +await tx.commit() +``` + +## Complete Example + +```typescript +import { createCollection } from '@tanstack/react-db' +import { localStorageCollectionOptions } from '@tanstack/react-db' +import { useLiveQuery } from '@tanstack/react-db' +import { z } from 'zod' + +// Define schema +const userPrefsSchema = z.object({ + id: z.string(), + theme: z.enum(['light', 'dark', 'auto']), + language: z.string(), + notifications: z.boolean(), +}) + +type UserPrefs = z.infer + +// Create collection +export const userPreferencesCollection = createCollection( + localStorageCollectionOptions({ + id: 'user-preferences', + storageKey: 'app-user-prefs', + getKey: (item) => item.id, + schema: userPrefsSchema, + }) +) + +// Use in component +function SettingsPanel() { + const { data: prefs } = useLiveQuery((q) => + q.from({ pref: userPreferencesCollection }) + .where(({ pref }) => pref.id === 'current-user') + ) + + const currentPrefs = prefs[0] + + const updateTheme = (theme: 'light' | 'dark' | 'auto') => { + if (currentPrefs) { + userPreferencesCollection.update(currentPrefs.id, (draft) => { + draft.theme = theme + }) + } else { + userPreferencesCollection.insert({ + id: 'current-user', + theme, + language: 'en', + notifications: true, + }) + } + } + + return ( +
+

Theme: {currentPrefs?.theme}

+ + +
+ ) +} +``` + +## Use Cases + +LocalStorage collections are perfect for: +- User preferences and settings +- UI state that should persist across sessions +- Form drafts +- Recently viewed items +- User-specific configurations +- Small amounts of cached data + +## Learn More + +- [Optimistic Mutations](../guides/mutations.md) +- [Live Queries](../guides/live-queries.md) +- [LocalOnly Collection](./local-only-collection.md) diff --git a/docs/collections/trailbase-collection.md b/docs/collections/trailbase-collection.md new file mode 100644 index 000000000..1b1d60d42 --- /dev/null +++ b/docs/collections/trailbase-collection.md @@ -0,0 +1,226 @@ +--- +title: TrailBase Collection +--- + +# TrailBase Collection + +TrailBase collections provide seamless integration between TanStack DB and [TrailBase](https://trailbase.io), enabling real-time data synchronization with TrailBase's self-hosted application backend. + +## Overview + +[TrailBase](https://trailbase.io) is an easy-to-self-host, single-executable application backend with built-in SQLite, a V8 JS runtime, auth, admin UIs and sync functionality. + +The `@tanstack/trailbase-db-collection` package allows you to create collections that: +- Automatically sync data from TrailBase Record APIs +- Support real-time subscriptions when `enable_subscriptions` is enabled +- Handle optimistic updates with automatic rollback on errors +- Provide parse/serialize functions for data transformation + +## Installation + +```bash +npm install @tanstack/trailbase-db-collection @tanstack/react-db trailbase +``` + +## Basic Usage + +```typescript +import { createCollection } from '@tanstack/react-db' +import { trailBaseCollectionOptions } from '@tanstack/trailbase-db-collection' +import { initClient } from 'trailbase' + +const trailBaseClient = initClient(`https://your-trailbase-instance.com`) + +const todosCollection = createCollection( + trailBaseCollectionOptions({ + id: 'todos', + recordApi: trailBaseClient.records('todos'), + getKey: (item) => item.id, + }) +) +``` + +## Configuration Options + +The `trailBaseCollectionOptions` function accepts the following options: + +### Required Options + +- `id`: Unique identifier for the collection +- `recordApi`: TrailBase Record API instance created via `trailBaseClient.records()` +- `getKey`: Function to extract the unique key from an item + +### Optional Options + +- `schema`: [Standard Schema](https://standardschema.dev) compatible schema (e.g., Zod, Effect) for client-side validation +- `parse`: Object mapping field names to parsing functions that transform data coming from TrailBase +- `serialize`: Object mapping field names to serialization functions that transform data going to TrailBase +- `onInsert`: Handler function called when items are inserted +- `onUpdate`: Handler function called when items are updated +- `onDelete`: Handler function called when items are deleted + +## Data Transformation + +TrailBase uses different data formats for storage (e.g., Unix timestamps). Use `parse` and `serialize` to handle these transformations: + +```typescript +type SelectTodo = { + id: string + text: string + created_at: number // Unix timestamp from TrailBase + completed: boolean +} + +type Todo = { + id: string + text: string + created_at: Date // JavaScript Date for app usage + completed: boolean +} + +const todosCollection = createCollection( + trailBaseCollectionOptions({ + id: 'todos', + recordApi: trailBaseClient.records('todos'), + getKey: (item) => item.id, + schema: todoSchema, + // Transform TrailBase data to application format + parse: { + created_at: (ts) => new Date(ts * 1000), + }, + // Transform application data to TrailBase format + serialize: { + created_at: (date) => Math.floor(date.valueOf() / 1000), + }, + }) +) +``` + +## Real-time Subscriptions + +TrailBase supports real-time subscriptions when enabled on the server. The collection automatically subscribes to changes and updates in real-time: + +```typescript +const todosCollection = createCollection( + trailBaseCollectionOptions({ + id: 'todos', + recordApi: trailBaseClient.records('todos'), + getKey: (item) => item.id, + // Real-time updates work automatically when + // enable_subscriptions is set in TrailBase config + }) +) + +// Changes from other clients will automatically update +// the collection in real-time +``` + +## Mutation Handlers + +Handle inserts, updates, and deletes by providing mutation handlers: + +```typescript +const todosCollection = createCollection( + trailBaseCollectionOptions({ + id: 'todos', + recordApi: trailBaseClient.records('todos'), + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const newTodo = transaction.mutations[0].modified + // TrailBase handles the persistence automatically + // Add custom logic here if needed + }, + onUpdate: async ({ transaction }) => { + const { original, modified } = transaction.mutations[0] + // TrailBase handles the persistence automatically + // Add custom logic here if needed + }, + onDelete: async ({ transaction }) => { + const deletedTodo = transaction.mutations[0].original + // TrailBase handles the persistence automatically + // Add custom logic here if needed + }, + }) +) +``` + +## Complete Example + +```typescript +import { createCollection } from '@tanstack/react-db' +import { trailBaseCollectionOptions } from '@tanstack/trailbase-db-collection' +import { initClient } from 'trailbase' +import { z } from 'zod' + +const trailBaseClient = initClient(`https://your-trailbase-instance.com`) + +// Define schema +const todoSchema = z.object({ + id: z.string(), + text: z.string(), + completed: z.boolean(), + created_at: z.date(), +}) + +type SelectTodo = { + id: string + text: string + completed: boolean + created_at: number +} + +type Todo = z.infer + +// Create collection +export const todosCollection = createCollection( + trailBaseCollectionOptions({ + id: 'todos', + recordApi: trailBaseClient.records('todos'), + getKey: (item) => item.id, + schema: todoSchema, + parse: { + created_at: (ts) => new Date(ts * 1000), + }, + serialize: { + created_at: (date) => Math.floor(date.valueOf() / 1000), + }, + onInsert: async ({ transaction }) => { + const newTodo = transaction.mutations[0].modified + console.log('Todo created:', newTodo) + }, + }) +) + +// Use in component +function TodoList() { + const { data: todos } = useLiveQuery((q) => + q.from({ todo: todosCollection }) + .where(({ todo }) => !todo.completed) + .orderBy(({ todo }) => todo.created_at, 'desc') + ) + + const addTodo = (text: string) => { + todosCollection.insert({ + id: crypto.randomUUID(), + text, + completed: false, + created_at: new Date(), + }) + } + + return ( +
+ {todos.map((todo) => ( +
{todo.text}
+ ))} +
+ ) +} +``` + +## Learn More + +- [TrailBase Documentation](https://trailbase.io/documentation/) +- [TrailBase Record APIs](https://trailbase.io/documentation/apis_record/) +- [Optimistic Mutations](../guides/mutations.md) +- [Live Queries](../guides/live-queries.md) diff --git a/docs/guides/mutations.md b/docs/guides/mutations.md index 24e8aba92..a07c7c521 100644 --- a/docs/guides/mutations.md +++ b/docs/guides/mutations.md @@ -814,8 +814,12 @@ const userProfile = createCollection( const tx = createTransaction({ mutationFn: async ({ transaction }) => { - // Server collection mutations are handled by their onUpdate handler automatically - // (onUpdate will be called and awaited first) + // Handle server collection mutations explicitly in mutationFn + await Promise.all( + transaction.mutations + .filter((m) => m.collection === userProfile) + .map((m) => api.profile.update(m.modified)) + ) // After server mutations succeed, accept local collection mutations localSettings.utils.acceptMutations(transaction) diff --git a/docs/overview.md b/docs/overview.md index b5597a335..dad45bfe1 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -127,374 +127,44 @@ With an instant inner loop of optimistic state, superseded in time by the slower ### Collections -There are a number of built-in collection types: +TanStack DB provides several built-in collection types for different data sources and use cases. Each collection type has its own detailed documentation page: -1. [`QueryCollection`](#querycollection) to load data into collections using [TanStack Query](https://tanstack.com/query) -2. [`ElectricCollection`](#electriccollection) to sync data into collections using [ElectricSQL](https://electric-sql.com) -3. [`TrailBaseCollection`](#trailbasecollection) to sync data into collections using [TrailBase](https://trailbase.io) -4. [`RxDBCollection`](#rxdbcollection) to integrate with [RxDB](https://rxdb.info) for local persistence and sync -5. [`LocalStorageCollection`](#localstoragecollection) for small amounts of local-only state that syncs across browser tabs -6. [`LocalOnlyCollection`](#localonlycollection) for in-memory client data or UI state +#### Built-in Collection Types -You can also use: +**Fetch Collections** -- use live collection queries to [derive collections from other collections](#derived-collections) -- the [base Collection](#base-collection) to define your own collection types +- **[QueryCollection](./collections/query-collection.md)** — Load data into collections using TanStack Query for REST APIs and data fetching. -#### Collection schemas +**Sync Collections** -All collections optionally (though strongly recommended) support adding a `schema`. - -If provided, this must be a [Standard Schema](https://standardschema.dev) compatible schema instance, such as a [Zod](https://zod.dev) or [Effect](https://effect.website/docs/schema/introduction/) schema. - -The collection will use the schema to do client-side validation of optimistic mutations. - -The collection will use the schema for its type so if you provide a schema, you can't also pass in an explicit -type (e.g. `createCollection()`). - -#### `QueryCollection` - -[TanStack Query](https://tanstack.com/query) fetches data using managed queries. Use `queryCollectionOptions` to fetch data into a collection using TanStack Query: - -```ts -import { createCollection } from "@tanstack/react-db" -import { queryCollectionOptions } from "@tanstack/query-db-collection" - -const todoCollection = createCollection( - queryCollectionOptions({ - queryKey: ["todoItems"], - queryFn: async () => { - const response = await fetch("/api/todos") - return response.json() - }, - getKey: (item) => item.id, - schema: todoSchema, // any standard schema - }) -) -``` - -The collection will be populated with the query results. +- **[ElectricCollection](./collections/electric-collection.md)** — Sync data into collections from Postgres using ElectricSQL's real-time sync engine. -#### `ElectricCollection` +- **[TrailBaseCollection](./collections/trailbase-collection.md)** — Sync data into collections using TrailBase's self-hosted backend with real-time subscriptions. -[Electric](https://electric-sql.com) is a read-path sync engine for Postgres. It allows you to sync subsets of data out of a Postgres database, [through your API](https://electric-sql.com/blog/2024/11/21/local-first-with-your-existing-api), into a TanStack DB collection. +- **[RxDBCollection](./collections/rxdb-collection.md)** — Integrate with RxDB for offline-first local persistence with powerful replication and sync capabilities. -Electric's main primitive for sync is a [Shape](https://electric-sql.com/docs/guides/shapes). Use `electricCollectionOptions` to sync a shape into a collection: +**Local Collections** -```ts -import { createCollection } from "@tanstack/react-db" -import { electricCollectionOptions } from "@tanstack/electric-db-collection" +- **[LocalStorageCollection](./collections/local-storage-collection.md)** — Store small amounts of local-only state that persists across sessions and syncs across browser tabs. -export const todoCollection = createCollection( - electricCollectionOptions({ - id: "todos", - shapeOptions: { - url: "https://example.com/v1/shape", - params: { - table: "todos", - }, - }, - getKey: (item) => item.id, - schema: todoSchema, - }) -) -``` - -The Electric collection requires two Electric-specific options: - -- `shapeOptions` — the Electric [ShapeStreamOptions](https://electric-sql.com/docs/api/clients/typescript#options) that define the [Shape](https://electric-sql.com/docs/guides/shapes) to sync into the collection; this includes the - - `url` to your sync engine; and - - `params` to specify the `table` to sync and any optional `where` clauses, etc. -- `getKey` — identifies the id for the rows being synced into the collection - -A new collections doesn't start syncing until you call `collection.preload()` or you query it. +- **[LocalOnlyCollection](./collections/local-only-collection.md)** — Manage in-memory client data or UI state that doesn't need persistence or cross-tab sync. -Electric shapes allow you to filter data using where clauses: +#### Collection Schemas -```ts -export const myPendingTodos = createCollection( - electricCollectionOptions({ - id: "todos", - shapeOptions: { - url: "https://example.com/v1/shape", - params: { - table: "todos", - where: ` - status = 'pending' - AND - user_id = '${user.id}' - `, - }, - }, - getKey: (item) => item.id, - schema: todoSchema, - }) -) -``` - -> [!TIP] -> Shape where clauses, used to filter the data you sync into `ElectricCollection`s, are different from the [live queries](#live-queries) you use to query data in components. -> -> Live queries are much more expressive than shapes, allowing you to query across collections, join, aggregate, etc. Shapes just contain filtered database tables and are used to populate the data in a collection. - -If you need more control over what data syncs into the collection, Electric allows you to [use your API](https://electric-sql.com/blog/2024/11/21/local-first-with-your-existing-api#filtering) as a proxy to both authorize and filter data. - -See the [Electric docs](https://electric-sql.com/docs/intro) for more information. - -#### `TrailBaseCollection` - -[TrailBase](https://trailbase.io) is an easy-to-self-host, single-executable application backend with built-in SQLite, a V8 JS runtime, auth, admin UIs and sync functionality. - -TrailBase lets you expose tables via [Record APIs](https://trailbase.io/documentation/apis_record/) and subscribe to changes when `enable_subscriptions` is set. Use `trailBaseCollectionOptions` to sync records into a collection: - -```ts -import { createCollection } from "@tanstack/react-db" -import { trailBaseCollectionOptions } from "@tanstack/trailbase-db-collection" -import { initClient } from "trailbase" - -const trailBaseClient = initClient(`https://trailbase.io`) - -export const todoCollection = createCollection( - trailBaseCollectionOptions({ - id: "todos", - recordApi: trailBaseClient.records(`todos`), - getKey: (item) => item.id, - schema: todoSchema, - parse: { - created_at: (ts) => new Date(ts * 1000), - }, - serialize: { - created_at: (date) => Math.floor(date.valueOf() / 1000), - }, - }) -) -``` - -This collection requires the following TrailBase-specific options: - -- `recordApi` — identifies the API to sync. -- `getKey` — identifies the id for the records being synced into the collection. -- `parse` — maps `(v: Todo[k]) => SelectTodo[k]`. -- `serialize` — maps `(v: SelectTodo[k]) => Todo[k]`. - -A new collections doesn't start syncing until you call `collection.preload()` or you query it. - -#### `RxDBCollection` - -[RxDB](https://rxdb.info) is a client-side database for JavaScript apps with replication, conflict resolution, and offline-first features. -Use `rxdbCollectionOptions` from `@tanstack/rxdb-db-collection` to integrate an RxDB collection with TanStack DB: - -```ts -import { createCollection } from "@tanstack/react-db" -import { rxdbCollectionOptions } from "@tanstack/rxdb-db-collection" -import { createRxDatabase } from "rxdb" - -const db = await createRxDatabase({ - name: "mydb", - storage: getRxStorageMemory(), -}) -await db.addCollections({ - todos: { - schema: { - version: 0, - primaryKey: "id", - type: "object", - properties: { - id: { type: "string", maxLength: 100 }, - text: { type: "string" }, - completed: { type: "boolean" }, - }, - }, - }, -}) - -// Wrap the RxDB collection with TanStack DB -export const todoCollection = createCollection( - rxdbCollectionOptions({ - rxCollection: db.todos, - startSync: true, - }) -) -``` - -With this integration: - -- TanStack DB subscribes to RxDB's change streams and reflects updates, deletes, and inserts in real-time. -- You get local-first sync when RxDB replication is configured. -- Mutation handlers (onInsert, onUpdate, onDelete) are implemented using RxDB's APIs (bulkUpsert, incrementalPatch, bulkRemove). - -This makes RxDB a great choice for apps that need local-first storage, replication, or peer-to-peer sync combined with TanStack DB's live queries and transaction lifecycle. - -#### `LocalStorageCollection` - -localStorage collections store small amounts of local-only state that persists across browser sessions and syncs across browser tabs in real-time. All data is stored under a single localStorage key and automatically synchronized using storage events. - -Use `localStorageCollectionOptions` to create a collection that stores data in localStorage: - -```ts -import { createCollection } from "@tanstack/react-db" -import { localStorageCollectionOptions } from "@tanstack/react-db" - -export const userPreferencesCollection = createCollection( - localStorageCollectionOptions({ - id: "user-preferences", - storageKey: "app-user-prefs", // localStorage key - getKey: (item) => item.id, - schema: userPrefsSchema, - }) -) -``` - -The localStorage collection requires: - -- `storageKey` — the localStorage key where all collection data is stored -- `getKey` — identifies the id for items in the collection - -Mutation handlers (`onInsert`, `onUpdate`, `onDelete`) are completely optional. Data will persist to localStorage whether or not you provide handlers. You can provide alternative storage backends like `sessionStorage` or custom implementations that match the localStorage API. - -```ts -export const sessionCollection = createCollection( - localStorageCollectionOptions({ - id: "session-data", - storageKey: "session-key", - storage: sessionStorage, // Use sessionStorage instead - getKey: (item) => item.id, - }) -) -``` - -> [!TIP] -> localStorage collections are perfect for user preferences, UI state, and other data that should persist locally but doesn't need server synchronization. For server-synchronized data, use [`QueryCollection`](#querycollection) or [`ElectricCollection`](#electriccollection) instead. - -#### `LocalOnlyCollection` - -LocalOnly collections are designed for in-memory client data or UI state that doesn't need to persist across browser sessions or sync across tabs. They provide a simple way to manage temporary, session-only data with full optimistic mutation support. - -Use `localOnlyCollectionOptions` to create a collection that stores data only in memory: - -```ts -import { createCollection } from "@tanstack/react-db" -import { localOnlyCollectionOptions } from "@tanstack/react-db" - -export const uiStateCollection = createCollection( - localOnlyCollectionOptions({ - id: "ui-state", - getKey: (item) => item.id, - schema: uiStateSchema, - // Optional initial data to populate the collection - initialData: [ - { id: "sidebar", isOpen: false }, - { id: "theme", mode: "light" }, - ], - }) -) -``` - -The LocalOnly collection requires: - -- `getKey` — identifies the id for items in the collection - -Optional configuration: - -- `initialData` — array of items to populate the collection with on creation -- `onInsert`, `onUpdate`, `onDelete` — optional mutation handlers for custom logic - -Mutation handlers are completely optional. When provided, they are called before the optimistic state is confirmed. The collection automatically manages the transition from optimistic to confirmed state internally. - -```ts -export const tempDataCollection = createCollection( - localOnlyCollectionOptions({ - id: "temp-data", - getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - // Custom logic before confirming the insert - console.log("Inserting:", transaction.mutations[0].modified) - }, - onUpdate: async ({ transaction }) => { - // Custom logic before confirming the update - const { original, modified } = transaction.mutations[0] - console.log("Updating from", original, "to", modified) - }, - }) -) -``` - -> [!TIP] -> LocalOnly collections are perfect for temporary UI state, form data, or any client-side data that doesn't need persistence. For data that should persist across sessions, use [`LocalStorageCollection`](#localstoragecollection) instead. - -**Using LocalStorage and LocalOnly Collections with Manual Transactions:** - -When using either LocalStorage or LocalOnly collections with manual transactions (created via `createTransaction`), you must call `utils.acceptMutations()` in your transaction's `mutationFn` to persist the changes. This is necessary because these collections don't participate in the standard mutation handler flow for manual transactions. - -```ts -import { createTransaction } from "@tanstack/react-db" - -const localData = createCollection( - localOnlyCollectionOptions({ - id: "form-draft", - getKey: (item) => item.id, - }) -) - -const serverCollection = createCollection( - queryCollectionOptions({ - queryKey: ["items"], - queryFn: async () => api.items.getAll(), - getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - await api.items.create(transaction.mutations[0].modified) - }, - }) -) - -const tx = createTransaction({ - mutationFn: async ({ transaction }) => { - // Server collection mutations are handled by their onInsert handler automatically - // (onInsert will be called and awaited) - - // After server mutations succeed, persist local collection mutations - localData.utils.acceptMutations(transaction) - }, -}) - -// Apply mutations to both collections in one transaction -tx.mutate(() => { - localData.insert({ id: "draft-1", data: "..." }) - serverCollection.insert({ id: "1", name: "Item" }) -}) - -await tx.commit() -``` - -#### Derived collections - -Live queries return collections. This allows you to derive collections from other collections. - -For example: - -```ts -import { createLiveQueryCollection, eq } from "@tanstack/db" +All collections optionally (though strongly recommended) support adding a `schema`. -// Imagine you have a collection of todos. -const todoCollection = createCollection({ - // config -}) +If provided, this must be a [Standard Schema](https://standardschema.dev) compatible schema instance, such as a [Zod](https://zod.dev) or [Effect](https://effect.website/docs/schema/introduction/) schema. -// You can derive a new collection that's a subset of it. -const completedTodoCollection = createLiveQueryCollection({ - startSync: true, - query: (q) => - q.from({ todo: todoCollection }).where(({ todo }) => todo.completed), -}) -``` +The collection will use the schema to do client-side validation of optimistic mutations. -This also works with joins to derive collections from multiple source collections. And it works recursively -- you can derive collections from other derived collections. Changes propagate efficiently using differential dataflow and it's collections all the way down. +The collection will use the schema for its type so if you provide a schema, you can't also pass in an explicit +type (e.g. `createCollection()`). -#### Collection +#### Creating Custom Collection Types -There is a `Collection` interface in [`../packages/db/src/collection.ts`](https://github.com/TanStack/db/blob/main/packages/db/src/collection.ts). You can use this to implement your own collection types. +You can create your own collection types by implementing the `Collection` interface found in [`../packages/db/src/collection.ts`](https://github.com/TanStack/db/blob/main/packages/db/src/collection.ts). -See the existing implementations in [`../packages/db`](https://github.com/TanStack/db/tree/main/packages/db), [`../packages/query-db-collection`](https://github.com/TanStack/db/tree/main/packages/query-db-collection), [`../packages/electric-db-collection`](https://github.com/TanStack/db/tree/main/packages/electric-db-collection) and [`../packages/trailbase-db-collection`](https://github.com/TanStack/db/tree/main/packages/trailbase-db-collection) for reference. +See the existing implementations in [`../packages/db`](https://github.com/TanStack/db/tree/main/packages/db), [`../packages/query-db-collection`](https://github.com/TanStack/db/tree/main/packages/query-db-collection), [`../packages/electric-db-collection`](https://github.com/TanStack/db/tree/main/packages/electric-db-collection) and [`../packages/trailbase-db-collection`](https://github.com/TanStack/db/tree/main/packages/trailbase-db-collection) for reference. Also see the [Collection Options Creator guide](./guides/collection-options-creator.md) for a pattern to create reusable collection configuration factories. ### Live queries @@ -592,7 +262,7 @@ See the [Mutations guide](../guides/mutations.md) for comprehensive documentatio Here we illustrate two common ways of using TanStack DB: 1. [using TanStack Query](#1-tanstack-query) with an existing REST API -2. [using the ElectricSQL sync engine](#2-electricsql-sync) with a generic ingestion endpoint +2. [using the ElectricSQL sync engine](#2-electricsql-sync) for real-time sync with your existing API > [!TIP] > You can combine these patterns. One of the benefits of TanStack DB is that you can integrate different ways of loading data and handling mutations into the same app. Your components don't need to know where the data came from or goes. @@ -603,8 +273,8 @@ You can use TanStack DB with your existing REST API via TanStack Query. The steps are to: -1. create [`QueryCollection`](#querycollection)s that load data using TanStack Query -2. implement [`mutationFn`](#mutationfn)s that handle mutations by posting them to your API endpoints +1. create [QueryCollection](./collections/query-collection.md)s that load data using TanStack Query +2. implement mutation handlers that handle mutations by posting them to your API endpoints ```tsx import { useLiveQuery, createCollection } from "@tanstack/react-db"