diff --git a/.changeset/config.json b/.changeset/config.json index 32c538a08..cc95104ae 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": [ "@svitejs/changesets-changelog-github-compact", - { "repo": "TanStack/optimistic" } + { "repo": "TanStack/db" } ], "commit": false, "access": "public", diff --git a/.changeset/eighty-ideas-clean.md b/.changeset/eighty-ideas-clean.md new file mode 100644 index 000000000..c0eb9abe5 --- /dev/null +++ b/.changeset/eighty-ideas-clean.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Improved the type of the queryFn's ctx.meta property of the Query Collection to include the loadSubsetOptions diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..8fbf8f760 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,42 @@ +--- +name: πŸ› Bug Report +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +- [ ] I've validated the bug against the latest version of DB packages + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..fa2007e87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: πŸ€” Feature Requests & Questions + url: https://github.com/TanStack/db/discussions + about: Please ask and answer questions here. + - name: πŸ’¬ Community Chat + url: https://discord.gg/mQd7egN + about: A dedicated discord server hosted by TanStack + - name: πŸ¦‹ TanStack Bluesky + url: https://bsky.app/profile/tanstack.com + about: Stay up to date with new releases of our libraries diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..cfc170dbe --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## 🎯 Changes + + + +## βœ… Checklist + +- [ ] I have followed the steps in the [Contributing guide](https://github.com/TanStack/db/blob/main/CONTRIBUTING.md). +- [ ] I have tested this code locally with `pnpm test:pr`. + +## πŸš€ Release Impact + +- [ ] This change affects published code, and I have generated a [changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). +- [ ] This change is docs/CI/dev-only (no release). diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..aa175e3b7 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "configMigration": true, + "extends": [ + "config:recommended", + "group:allNonMajor", + "schedule:weekly", + ":approveMajorUpdates", + ":automergeMinor", + ":disablePeerDependencies", + ":maintainLockFilesMonthly", + ":semanticCommits", + ":semanticCommitTypeAll(chore)" + ], + "ignorePresets": [":ignoreModulesAndTests"], + "labels": ["dependencies"], + "rangeStrategy": "bump", + "postUpdateOptions": ["pnpmDedupe"], + "ignoreDeps": ["@types/node", "node", "typescript"] +} diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index d75eaf0cb..933a6edc4 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -18,12 +18,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 - name: Setup Tools uses: tanstack/config/.github/setup@main - name: Fix formatting run: pnpm prettier --ignore-unknown . --check - name: Apply fixes - uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef + uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 with: commit-message: "ci: apply automated fixes" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..72623ed4d --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,27 @@ +name: Claude Code +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && (github.event.comment.user.login == 'kevin-dp' || github.event.comment.user.login == 'KyleAMathews' || github.event.comment.user.login == 'samwillis')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && (github.event.comment.user.login == 'kevin-dp' || github.event.comment.user.login == 'KyleAMathews' || github.event.comment.user.login == 'samwillis')) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # required for Claude Code + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Responds to @claude mentions in comments diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 000000000..c041168d4 --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,104 @@ +name: Sync Generated Docs + +on: + schedule: + # Run daily at 2 AM UTC + - cron: "0 2 * * *" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync-docs: + name: Generate and Sync Docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 + + - name: Setup Tools + uses: tanstack/config/.github/setup@main + + - name: Build Packages + run: pnpm run build + + - name: Generate Docs + run: pnpm docs:generate + + - name: Check for changes + id: check_changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "Changes detected in generated docs" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No changes in generated docs" + fi + + - name: Configure Git + if: steps.check_changes.outputs.has_changes == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit and Push Changes + if: steps.check_changes.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME="docs/auto-generate" + + # Check if branch exists remotely + if git ls-remote --exit-code --heads origin $BRANCH_NAME; then + echo "Branch exists, checking out and updating" + git fetch origin $BRANCH_NAME + git checkout $BRANCH_NAME + git pull origin $BRANCH_NAME + else + echo "Creating new branch" + git checkout -b $BRANCH_NAME + fi + + # Stage and commit changes + git add docs/ + git commit -m "docs: regenerate API documentation + + Auto-generated by daily docs sync workflow" + + # Push changes + git push origin $BRANCH_NAME + + - name: Create or Update PR + if: steps.check_changes.outputs.has_changes == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH_NAME="docs/auto-generate" + + # Check if PR already exists + existing_pr=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number') + + if [ -n "$existing_pr" ]; then + echo "PR #$existing_pr already exists, it has been updated with the latest changes" + gh pr comment $existing_pr --body "Updated with latest generated docs from scheduled workflow run." + else + echo "Creating new PR" + gh pr create \ + --title "docs: sync generated API documentation" \ + --body "This PR was automatically created by the daily docs sync workflow. + + The generated API documentation has been updated to reflect the latest changes in the codebase. + + **Generated by**: [Docs Sync Workflow](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + Please review and merge if the changes look correct." \ + --head $BRANCH_NAME \ + --base main + fi diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 000000000..16cadf13a --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,77 @@ +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + e2e-tests: + name: Run E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.22.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Start Docker services + run: | + cd packages/db-collection-e2e/docker + docker compose up -d + echo "Waiting for services to be healthy..." + timeout 60 bash -c 'until docker compose ps | grep -q "healthy"; do sleep 2; done' + + - name: Build packages + run: | + pnpm --filter @tanstack/db-ivm build + pnpm --filter @tanstack/db build + pnpm --filter @tanstack/electric-db-collection build + pnpm --filter @tanstack/query-db-collection build + + - name: Run Electric E2E tests + run: | + cd packages/electric-db-collection + pnpm test:e2e + env: + ELECTRIC_URL: http://localhost:3000 + POSTGRES_HOST: localhost + POSTGRES_PORT: 54321 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: e2e_test + + - name: Run Query E2E tests + run: | + cd packages/query-db-collection + pnpm test:e2e + env: + ELECTRIC_URL: http://localhost:3000 + + - name: Stop Docker services + if: always() + run: | + cd packages/db-collection-e2e/docker + docker compose down -v + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: packages/db-collection-e2e/junit/ + retention-days: 7 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 14d56eeaa..4e8e3d55f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -3,6 +3,9 @@ name: PR on: pull_request: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.ref }} cancel-in-progress: true @@ -20,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: fetch-depth: 0 - name: Setup Tools uses: tanstack/config/.github/setup@main - name: Get base and head commits for `nx affected` - uses: nrwl/nx-set-shas@v4.3.0 + uses: nrwl/nx-set-shas@v4.4.0 with: main-branch-name: main - name: Run Checks @@ -36,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: fetch-depth: 0 - name: Setup Tools @@ -51,18 +54,20 @@ jobs: repo-token: "${{ secrets.GITHUB_TOKEN }}" pattern: "./packages/db/dist/**/*.{js,mjs}" comment-key: "db-package-size" + build-script: "build:minified" - name: Compressed Size Action - React DB Package uses: preactjs/compressed-size-action@v2 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" pattern: "./packages/react-db/dist/**/*.{js,mjs}" comment-key: "react-db-package-size" + build-script: "build:minified" build-example: name: Build Example Site runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 - name: Setup Tools uses: tanstack/config/.github/setup@main - name: Build Packages @@ -71,3 +76,17 @@ jobs: run: | cd examples/react/todo pnpm build + build-starter: + name: Build Example Site + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5.0.0 + - name: Setup Tools + uses: tanstack/config/.github/setup@main + - name: Build Packages + run: pnpm run build + - name: Build Starter Site + run: | + cd examples/react/projects + pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7749bb0b4..e79338f00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v5.0.0 with: fetch-depth: 0 - name: Setup Tools @@ -31,7 +31,8 @@ jobs: - name: Run Tests run: pnpm run lint && pnpm run build && pnpm run test - name: Run Changesets (version or publish) - uses: changesets/action@v1.4.9 + id: changesets + uses: changesets/action@v1.5.3 with: version: pnpm run changeset:version publish: pnpm run changeset:publish @@ -40,3 +41,36 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Generate Docs + if: steps.changesets.outputs.published == 'true' + run: pnpm docs:generate + - name: Commit Generated Docs + if: steps.changesets.outputs.published == 'true' + run: | + if [ -n "$(git status --porcelain)" ]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + BRANCH="docs/auto-update-$(date +%s)" + git checkout -b "$BRANCH" + git add docs/ + git commit -m "docs: regenerate API documentation" + git push origin "$BRANCH" + + gh pr create \ + --title "docs: regenerate API documentation" \ + --body "Automated documentation update from release" \ + --base main \ + --head "$BRANCH" + + gh pr merge "$BRANCH" --auto --squash + else + echo "No changes in generated docs" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Comment on PRs about release + if: steps.changesets.outputs.published == 'true' + uses: tanstack/config/.github/comment-on-release@main + with: + published-packages: ${{ steps.changesets.outputs.publishedPackages }} diff --git a/.gitignore b/.gitignore index 528e53f21..9e71f3da7 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,4 @@ tasks/ .output .tanstack .claude +package-lock.json diff --git a/.npmrc b/.npmrc index 84aee8d99..268c392d3 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1 @@ -link-workspace-packages=true -prefer-workspace-packages=true provenance=true diff --git a/.nvmrc b/.nvmrc index d5b283a3a..b40402760 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.13.1 +24.8.0 diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 000000000..f154da33d --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,25 @@ +function readPackage(pkg, context) { + // Force all @tanstack/db dependencies to resolve to workspace version + if (pkg.dependencies && pkg.dependencies["@tanstack/db"]) { + pkg.dependencies["@tanstack/db"] = "workspace:*" + context.log(`Overriding @tanstack/db dependency in ${pkg.name}`) + } + + if (pkg.devDependencies && pkg.devDependencies["@tanstack/db"]) { + pkg.devDependencies["@tanstack/db"] = "workspace:*" + context.log(`Overriding @tanstack/db devDependency in ${pkg.name}`) + } + + if (pkg.peerDependencies && pkg.peerDependencies["@tanstack/db"]) { + pkg.peerDependencies["@tanstack/db"] = "workspace:*" + context.log(`Overriding @tanstack/db peerDependency in ${pkg.name}`) + } + + return pkg +} + +module.exports = { + hooks: { + readPackage, + }, +} diff --git a/README.md b/README.md index be627912f..9b12f0958 100644 --- a/README.md +++ b/README.md @@ -1,197 +1,130 @@ -# TanStack DB - - - -**A reactive client store for building super fast apps on sync** - -TanStack DB extends TanStack Query with collections, live queries and optimistic mutations that keep your UI reactive, consistent and blazing fast πŸ”₯ - -

- - #TanStack - - Status - Alpha - - - - - - Join the discussion on Github - - -

- -Enjoy this library? Try the entire [TanStack](https://tanstack.com), including [TanStack Query](https://tanstack.com/query), [TanStack Store](https://tanstack.com/store), etc. - -## πŸš€ Why TanStack DB? - -TanStack DB gives you robust support for real-time sync, live queries and local writes. With no stale data, super fast re-rendering and sub-millisecond cross-collection queries β€” even for large complex apps. - -Built on a TypeScript implementation of differential dataflow ([#](https://github.com/electric-sql/d2ts)), TanStack DB gives you: - -- πŸ”₯ **a blazing fast query engine**
- for sub-millisecond live queries — even for complex queries with joins and aggregates -- 🎯 **fine-grained reactivity**
- to minimize component re-rendering -- πŸ’ͺ **robust transaction primitives**
- for easy optimistic mutations with sync and lifecycle support -- 🌟 **normalized data**
- to keep your backend simple - -TanStack DB is **backend agnostic** and **incrementally adoptable**: - -- plug in any backend: sync engines, REST APIs, GraphQL, polling, custom sources -- builds on [TanStack Store](https://tanstack.com/store), works with and alongside [TanStack Query](https://tanstack.com/query) - -## πŸ’₯ Usage example - -Sync data into collections: - -```ts -import { createCollection, QueryClient } from "@tanstack/react-db" -import { queryCollectionOptions } from "@tanstack/query-db-collection" - -const todoCollection = createCollection( - queryCollectionOptions({ - queryKey: ["todos"], - queryFn: async () => fetch("/api/todos"), - queryClient: new QueryClient(), - getKey: (item) => item.id, - schema: todoSchema, // any standard schema - }) -) -``` - -Use live queries in your components: - -```tsx -import { useLiveQuery } from "@tanstack/react-db" -import { eq } from "@tanstack/db" - -const Todos = () => { - const { data: todos } = useLiveQuery((query) => - query - .from({ todos: todoCollection }) - .where(({ todos }) => eq(todos.completed, false)) - ) - - return -} -``` - -Apply mutations with local optimistic state: - -```tsx -// Define collection with persistence handlers -const todoCollection = createCollection({ - id: "todos", - // ... other config - onInsert: async ({ transaction }) => { - const modified = transaction.mutations[0].modified - await api.todos.create(modified) - }, -}) - -// Then use collection operators in your components -const AddTodo = () => { - return ( - + + ) +} +``` + +## 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..3033c3bbd --- /dev/null +++ b/docs/collections/local-storage-collection.md @@ -0,0 +1,312 @@ +--- +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, + }) +) +``` + +### Direct Local Mutations + +**Important:** LocalStorage collections work differently than server-synced collections. With LocalStorage collections, you **directly mutate state** by calling methods like `collection.insert()`, `collection.update()`, and `collection.delete()` β€” that's all you need to do. The changes are immediately applied to your local data and automatically persisted to localStorage. + +This is different from collections that sync with a server (like Query Collection), where mutation handlers send data to a backend. With LocalStorage collections, everything stays local: + +```typescript +// Just call the methods directly - automatically persisted to localStorage +userPreferencesCollection.insert({ id: 'theme', mode: 'dark' }) +userPreferencesCollection.update('theme', (draft) => { draft.mode = 'light' }) +userPreferencesCollection.delete('theme') +``` + +## 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/powersync-collection.md b/docs/collections/powersync-collection.md new file mode 100644 index 000000000..afc665836 --- /dev/null +++ b/docs/collections/powersync-collection.md @@ -0,0 +1,475 @@ +--- +title: PowerSync Collection +--- + +# PowerSync Collection + +PowerSync collections provide seamless integration between TanStack DB and [PowerSync](https://powersync.com), enabling automatic synchronization between your in-memory TanStack DB collections and PowerSync's SQLite database. This gives you offline-ready persistence, real-time sync capabilities, and powerful conflict resolution. + +## Overview + +The `@tanstack/powersync-db-collection` package allows you to create collections that: + +- Automatically mirror the state of an underlying PowerSync SQLite database +- Reactively update when PowerSync records change +- Support optimistic mutations with rollback on error +- Provide persistence handlers to keep PowerSync in sync with TanStack DB transactions +- Use PowerSync's efficient SQLite-based storage engine +- Work with PowerSync's real-time sync features for offline-first scenarios +- Leverage PowerSync's built-in conflict resolution and data consistency guarantees +- Enable real-time synchronization with PostgreSQL, MongoDB and MySQL backends + +## 1. Installation + +Install the PowerSync collection package along with your preferred framework integration. +PowerSync currently works with Web, React Native and Node.js. The examples below use the Web SDK. +See the PowerSync quickstart [docs](https://docs.powersync.com/installation/quickstart-guide) for more details. + +```bash +npm install @tanstack/powersync-db-collection @powersync/web @journeyapps/wa-sqlite +``` + +### 2. Create a PowerSync Database and Schema + +```ts +import { Schema, Table, column } from "@powersync/web" + +// Define your schema +const APP_SCHEMA = new Schema({ + documents: new Table({ + name: column.text, + author: column.text, + created_at: column.text, + archived: column.integer, + }), +}) + +// Initialize PowerSync database +const db = new PowerSyncDatabase({ + database: { + dbFilename: "app.sqlite", + }, + schema: APP_SCHEMA, +}) +``` + +### 3. (optional) Configure Sync with a Backend + +```ts +import { + AbstractPowerSyncDatabase, + PowerSyncBackendConnector, + PowerSyncCredentials, +} from "@powersync/web" + +// TODO implement your logic here +class Connector implements PowerSyncBackendConnector { + fetchCredentials: () => Promise + + /** Upload local changes to the app backend. + * + * Use {@link AbstractPowerSyncDatabase.getCrudBatch} to get a batch of changes to upload. + * + * Any thrown errors will result in a retry after the configured wait period (default: 5 seconds). + */ + uploadData: (database: AbstractPowerSyncDatabase) => Promise +} + +// Configure the client to connect to a PowerSync service and your backend +db.connect(new Connector()) +``` + +### 4. Create a TanStack DB Collection + +There are two main ways to create a collection: using type inference or using schema validation. Type inference will infer collection types from the underlying PowerSync SQLite tables. Schema validation can be used for additional input/output validations and type transforms. + +#### Option 1: Using Table Type Inference + +The collection types are automatically inferred from the PowerSync schema table definition. The table is used to construct a default standard schema validator which is used internally to validate collection operations. + +Collection mutations accept SQLite types and queries report data with SQLite types. + +```ts +import { createCollection } from "@tanstack/react-db" +import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection" + +const documentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.documents, + }) +) + +/** Note: The types for input and output are defined as this */ +// Used for mutations like `insert` or `update` +type DocumentCollectionInput = { + id: string + name: string | null + author: string | null + created_at: string | null // SQLite TEXT + archived: number | null // SQLite integer +} +// The type of query/data results +type DocumentCollectionOutput = DocumentCollectionInput +``` + +The standard PowerSync SQLite types map to these TypeScript types: + +| PowerSync Column Type | TypeScript Type | Description | +| --------------------- | ---------------- | -------------------------------------------------------------------- | +| `column.text` | `string \| null` | Text values, commonly used for strings, JSON, dates (as ISO strings) | +| `column.integer` | `number \| null` | Integer values, also used for booleans (0/1) | +| `column.real` | `number \| null` | Floating point numbers | + +Note: All PowerSync column types are nullable by default. + +#### Option 2: SQLite Types with Schema Validation + +Additional validations for collection mutations can be performed with a custom schema. The Schema below asserts that +the `name`, `author` and `created_at` fields are required as input. `name` also has an additional string length check. + +Note: The input and output types specified in this example still satisfy the underlying SQLite types. An additional `deserializationSchema` is required if the typing differs. See the examples below for more details. + +The application logic (including the backend) should enforce that all incoming synced data passes validation with the `schema`. Failing to validate data will result in inconsistency of the collection data. This is a fatal error! An `onDeserializationError` handler must be provided to react to this case. + +```ts +import { createCollection } from "@tanstack/react-db" +import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection" +import { z } from "zod" + +// Schema validates SQLite types but adds constraints +const schema = z.object({ + id: z.string(), + name: z.string().min(3, { message: "Should be at least 3 characters" }), + author: z.string(), + created_at: z.string(), // SQLite TEXT for dates + archived: z.number(), +}) + +const documentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.documents, + schema, + onDeserializationError: (error) => { + // Present fatal error + }, + }) +) + +/** Note: The types for input and output are defined as this */ +// Used for mutations like `insert` or `update` +type DocumentCollectionInput = { + id: string + name: string + author: string + created_at: string // SQLite TEXT + archived: number // SQLite integer +} +// The type of query/data results +type DocumentCollectionOutput = DocumentCollectionInput +``` + +#### Option 3: Transform SQLite Input Types to Rich Output Types + +You can transform SQLite types to richer types (like Date objects) while keeping SQLite-compatible input types: + +Note: The Transformed types are provided by TanStackDB to the PowerSync SQLite persister. These types need to be serialized in +order to be persisted to SQLite. Most types are converted by default. For custom types, override the serialization by providing a +`serializer` param. + +The example below uses `nullable` columns, this is not a requirement. + +The application logic (including the backend) should enforce that all incoming synced data passes validation with the `schema`. Failing to validate data will result in inconsistency of the collection data. This is a fatal error! An `onDeserializationError` handler must be provided to react to this case. + +```ts +const schema = z.object({ + id: z.string(), + name: z.string().nullable(), + created_at: z + .string() + .nullable() + .transform((val) => (val ? new Date(val) : null)), // Transform SQLite TEXT to Date + archived: z + .number() + .nullable() + .transform((val) => (val != null ? val > 0 : null)), // Transform SQLite INTEGER to boolean +}) + +const documentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.documents, + schema, + onDeserializationError: (error) => { + // Present fatal error + }, + // Optional: custom column serialization + serializer: { + // Dates are serialized by default, this is just an example + created_at: (value) => (value ? value.toISOString() : null), + }, + }) +) + +/** Note: The types for input and output are defined as this */ +// Used for mutations like `insert` or `update` +type DocumentCollectionInput = { + id: string + name: string | null + author: string | null + created_at: string | null // SQLite TEXT + archived: number | null +} +// The type of query/data results +type DocumentCollectionOutput = { + id: string + name: string | null + author: string | null + created_at: Date | null // JS Date instance + archived: boolean | null // JS boolean +} +``` + +#### Option 4: Custom Input/Output Types with Deserialization + +The input and output types can be completely decoupled from the internal SQLite types. This can be used to accept rich values for input mutations. +We require an additional `deserializationSchema` in order to validate and transform incoming synced (SQLite) updates. This schema should convert the incoming SQLite update to the output type. + +The application logic (including the backend) should enforce that all incoming synced data passes validation with the `deserializationSchema`. Failing to validate data will result in inconsistency of the collection data. This is a fatal error! An `onDeserializationError` handler must be provided to react to this case. + +```ts +// Our input/output types use Date and boolean +const schema = z.object({ + id: z.string(), + name: z.string(), + author: z.string(), + created_at: z.date(), // Accept Date objects as input + archived: z.boolean(), // Accept Booleans as input +}) + +// Schema to transform from SQLite types to our output types +const deserializationSchema = z.object({ + id: z.string(), + name: z.string(), + author: z.string(), + created_at: z + .string() + .transform((val) => (new Date(val))), // SQLite TEXT to Date + archived: z + .number() + .transform((val) => (val > 0), // SQLite INTEGER to Boolean +}) + +const documentsCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.documents, + schema, + deserializationSchema, + onDeserializationError: (error) => { + // Present fatal error + }, + }) +) + +/** Note: The types for input and output are defined as this */ +// Used for mutations like `insert` or `update` +type DocumentCollectionInput = { + id: string + name: string + author: string + created_at: Date + archived: boolean +} +// The type of query/data results +type DocumentCollectionOutput = DocumentCollectionInput +``` + +## Features + +### Offline-First + +PowerSync collections are offline-first by default. All data is stored locally in a SQLite database, allowing your app to work without an internet connection. Changes are automatically synced when connectivity is restored. + +### Real-Time Sync + +When connected to a PowerSync backend, changes are automatically synchronized in real-time across all connected clients. The sync process handles: + +- Bi-directional sync with the server +- Conflict resolution +- Queue management for offline changes +- Automatic retries on connection loss + +### Working with Rich JavaScript Types + +PowerSync collections support rich JavaScript types like `Date`, `Boolean`, and custom objects while maintaining SQLite compatibility. The collection handles serialization and deserialization automatically: + +```typescript +import { z } from "zod" +import { Schema, Table, column } from "@powersync/web" +import { createCollection } from "@tanstack/react-db" +import { powerSyncCollectionOptions } from "@tanstack/powersync-db-collection" + +// Define PowerSync SQLite schema +const APP_SCHEMA = new Schema({ + tasks: new Table({ + title: column.text, + due_date: column.text, // Stored as ISO string in SQLite + completed: column.integer, // Stored as 0/1 in SQLite + metadata: column.text, // Stored as JSON string in SQLite + }), +}) + +// Define rich types schema +const taskSchema = z.object({ + id: z.string(), + title: z.string().nullable(), + due_date: z + .string() + .nullable() + .transform((val) => (val ? new Date(val) : null)), // Convert to Date + completed: z + .number() + .nullable() + .transform((val) => (val != null ? val > 0 : null)), // Convert to boolean + metadata: z + .string() + .nullable() + .transform((val) => (val ? JSON.parse(val) : null)), // Parse JSON +}) + +// Create collection with rich types +const tasksCollection = createCollection( + powerSyncCollectionOptions({ + database: db, + table: APP_SCHEMA.props.tasks, + schema: taskSchema, + }) +) + +// Work with rich types in your code +await tasksCollection.insert({ + id: crypto.randomUUID(), + title: "Review PR", + due_date: "2025-10-30T10:00:00Z", // String input is automatically converted to Date + completed: 0, // Number input is automatically converted to boolean + metadata: JSON.stringify({ priority: "high" }), +}) + +// Query returns rich types +const task = tasksCollection.get("task-1") +console.log(task.due_date instanceof Date) // true +console.log(typeof task.completed) // "boolean" +console.log(task.metadata.priority) // "high" +``` + +### Type Safety with Rich Types + +The collection maintains type safety throughout: + +```typescript +type TaskInput = { + id: string + title: string | null + due_date: string | null // Accept ISO string for mutations + completed: number | null // Accept 0/1 for mutations + metadata: string | null // Accept JSON string for mutations +} + +type TaskOutput = { + id: string + title: string | null + due_date: Date | null // Get Date object in queries + completed: boolean | null // Get boolean in queries + metadata: { + priority: string + [key: string]: any + } | null +} + +// TypeScript enforces correct types: +tasksCollection.insert({ + due_date: new Date(), // Error: Type 'Date' is not assignable to type 'string' +}) + +const task = tasksCollection.get("task-1") +task.due_date.getTime() // OK - TypeScript knows this is a Date +``` + +### Optimistic Updates + +Updates to the collection are applied optimistically to the local state first, then synchronized with PowerSync and the backend. If an error occurs during sync, the changes are automatically rolled back. + +## Configuration Options + +The `powerSyncCollectionOptions` function accepts the following options: + +```ts +interface PowerSyncCollectionConfig { + // Required options + database: PowerSyncDatabase + table: Table + + // Schema validation and type transformation + schema?: StandardSchemaV1 + deserializationSchema?: StandardSchemaV1 // Required for custom input types + onDeserializationError?: (error: StandardSchemaV1.FailureResult) => void // Required for custom input types + + // Optional Custom serialization + serializer?: { + [Key in keyof TOutput]?: (value: TOutput[Key]) => SQLiteCompatibleType + } + + // Performance tuning + syncBatchSize?: number // Control batch size for initial sync, defaults to 1000 +} +``` + +## Advanced Transactions + +When you need more control over transaction handling, such as batching multiple operations or handling complex transaction scenarios, you can use PowerSync's transaction system directly with TanStack DB transactions. + +```ts +import { createTransaction } from "@tanstack/react-db" +import { PowerSyncTransactor } from "@tanstack/powersync-db-collection" + +// Create a transaction that won't auto-commit +const batchTx = createTransaction({ + autoCommit: false, + mutationFn: async ({ transaction }) => { + // Use PowerSyncTransactor to apply the transaction to PowerSync + await new PowerSyncTransactor({ database: db }).applyTransaction( + transaction + ) + }, +}) + +// Perform multiple operations in the transaction +batchTx.mutate(() => { + // Add multiple documents in a single transaction + for (let i = 0; i < 5; i++) { + documentsCollection.insert({ + id: crypto.randomUUID(), + name: `Document ${i}`, + content: `Content ${i}`, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + } +}) + +// Commit the transaction +await batchTx.commit() + +// Wait for the changes to be persisted +await batchTx.isPersisted.promise +``` + +This approach allows you to: + +- Batch multiple operations into a single transaction +- Control when the transaction is committed +- Ensure all operations are atomic +- Wait for persistence confirmation +- Handle complex transaction scenarios diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md new file mode 100644 index 000000000..4f43082d7 --- /dev/null +++ b/docs/collections/query-collection.md @@ -0,0 +1,706 @@ +--- +title: Query Collection +--- + +# Query Collection + +Query collections provide seamless integration between TanStack DB and TanStack Query, enabling automatic synchronization between your local database and remote data sources. + +## Overview + +The `@tanstack/query-db-collection` package allows you to create collections that: + +- Automatically fetch remote data via TanStack Query +- Support optimistic updates with automatic rollback on errors +- Handle persistence through customizable mutation handlers +- Provide direct write capabilities for directly writing to the sync store + +## Installation + +```bash +npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db +``` + +## Basic Usage + +```typescript +import { QueryClient } from "@tanstack/query-core" +import { createCollection } from "@tanstack/db" +import { queryCollectionOptions } from "@tanstack/query-db-collection" + +const queryClient = new QueryClient() + +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: async () => { + const response = await fetch("/api/todos") + return response.json() + }, + queryClient, + getKey: (item) => item.id, + }) +) +``` + +## Configuration Options + +The `queryCollectionOptions` function accepts the following options: + +### Required Options + +- `queryKey`: The query key for TanStack Query +- `queryFn`: Function that fetches data from the server +- `queryClient`: TanStack Query client instance +- `getKey`: Function to extract the unique key from an item + +### Query Options + +- `select`: Function that lets extract array items when they're wrapped with metadata +- `enabled`: Whether the query should automatically run (default: `true`) +- `refetchInterval`: Refetch interval in milliseconds (default: 0 β€” set an interval to enable polling refetching) +- `retry`: Retry configuration for failed queries +- `retryDelay`: Delay between retries +- `staleTime`: How long data is considered fresh +- `meta`: Optional metadata that will be passed to the query function context + +### Collection Options + +- `id`: Unique identifier for the collection +- `schema`: Schema for validating items +- `sync`: Custom sync configuration +- `startSync`: Whether to start syncing immediately (default: `true`) + +### Persistence Handlers + +- `onInsert`: Handler called before insert operations +- `onUpdate`: Handler called before update operations +- `onDelete`: Handler called before delete operations + +## Persistence Handlers + +You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation: + +```typescript +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: fetchTodos, + queryClient, + getKey: (item) => item.id, + + onInsert: async ({ transaction }) => { + const newItems = transaction.mutations.map((m) => m.modified) + await api.createTodos(newItems) + // Returning nothing or { refetch: true } will trigger a refetch + // Return { refetch: false } to skip automatic refetch + }, + + onUpdate: async ({ transaction }) => { + const updates = transaction.mutations.map((m) => ({ + id: m.key, + changes: m.changes, + })) + await api.updateTodos(updates) + }, + + onDelete: async ({ transaction }) => { + const ids = transaction.mutations.map((m) => m.key) + await api.deleteTodos(ids) + }, + }) +) +``` + +### Controlling Refetch Behavior + +By default, after any persistence handler (`onInsert`, `onUpdate`, or `onDelete`) completes successfully, the query will automatically refetch to ensure the local state matches the server state. + +You can control this behavior by returning an object with a `refetch` property: + +```typescript +onInsert: async ({ transaction }) => { + await api.createTodos(transaction.mutations.map((m) => m.modified)) + + // Skip the automatic refetch + return { refetch: false } +} +``` + +This is useful when: + +- You're confident the server state matches what you sent +- You want to avoid unnecessary network requests +- You're handling state updates through other mechanisms (like WebSockets) + +## Utility Methods + +The collection provides these utility methods via `collection.utils`: + +- `refetch(opts?)`: Manually trigger a refetch of the query + - `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`) + - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) + - Returns `QueryObserverResult` for inspecting the result + +## Direct Writes + +Direct writes are intended for scenarios where the normal query/mutation flow doesn't fit your needs. They allow you to write directly to the synced data store, bypassing the optimistic update system and query refetch mechanism. + +### Understanding the Data Stores + +Query Collections maintain two data stores: + +1. **Synced Data Store** - The authoritative state synchronized with the server via `queryFn` +2. **Optimistic Mutations Store** - Temporary changes that are applied optimistically before server confirmation + +Normal collection operations (insert, update, delete) create optimistic mutations that are: + +- Applied immediately to the UI +- Sent to the server via persistence handlers +- Rolled back automatically if the server request fails +- Replaced with server data when the query refetches + +Direct writes bypass this system entirely and write directly to the synced data store, making them ideal for handling real-time updates from alternative sources. + +### When to Use Direct Writes + +Direct writes should be used when: + +- You need to sync real-time updates from WebSockets or server-sent events +- You're dealing with large datasets where refetching everything is too expensive +- You receive incremental updates or server-computed field updates +- You need to implement complex pagination or partial data loading scenarios + +### Individual Write Operations + +```typescript +// Insert a new item directly to the synced data store +todosCollection.utils.writeInsert({ + id: "1", + text: "Buy milk", + completed: false, +}) + +// Update an existing item in the synced data store +todosCollection.utils.writeUpdate({ id: "1", completed: true }) + +// Delete an item from the synced data store +todosCollection.utils.writeDelete("1") + +// Upsert (insert or update) in the synced data store +todosCollection.utils.writeUpsert({ + id: "1", + text: "Buy milk", + completed: false, +}) +``` + +These operations: + +- Write directly to the synced data store +- Do NOT create optimistic mutations +- Do NOT trigger automatic query refetches +- Update the TanStack Query cache immediately +- Are immediately visible in the UI + +### Batch Operations + +The `writeBatch` method allows you to perform multiple operations atomically. Any write operations called within the callback will be collected and executed as a single transaction: + +```typescript +todosCollection.utils.writeBatch(() => { + todosCollection.utils.writeInsert({ id: "1", text: "Buy milk" }) + todosCollection.utils.writeInsert({ id: "2", text: "Walk dog" }) + todosCollection.utils.writeUpdate({ id: "3", completed: true }) + todosCollection.utils.writeDelete("4") +}) +``` + +### Real-World Example: WebSocket Integration + +```typescript +// Handle real-time updates from WebSocket without triggering full refetches +ws.on("todos:update", (changes) => { + todosCollection.utils.writeBatch(() => { + changes.forEach((change) => { + switch (change.type) { + case "insert": + todosCollection.utils.writeInsert(change.data) + break + case "update": + todosCollection.utils.writeUpdate(change.data) + break + case "delete": + todosCollection.utils.writeDelete(change.id) + break + } + }) + }) +}) +``` + +### Example: Incremental Updates + +When the server returns computed fields (like server-generated IDs or timestamps), you can use the `onInsert` handler with `{ refetch: false }` to avoid unnecessary refetches while still syncing the server response: + +```typescript +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: fetchTodos, + queryClient, + getKey: (item) => item.id, + + onInsert: async ({ transaction }) => { + const newItems = transaction.mutations.map((m) => m.modified) + + // Send to server and get back items with server-computed fields + const serverItems = await api.createTodos(newItems) + + // Sync server-computed fields (like server-generated IDs, timestamps, etc.) + // to the collection's synced data store + todosCollection.utils.writeBatch(() => { + serverItems.forEach((serverItem) => { + todosCollection.utils.writeInsert(serverItem) + }) + }) + + // Skip automatic refetch since we've already synced the server response + // (optimistic state is automatically replaced when handler completes) + return { refetch: false } + }, + + onUpdate: async ({ transaction }) => { + const updates = transaction.mutations.map((m) => ({ + id: m.key, + changes: m.changes, + })) + const serverItems = await api.updateTodos(updates) + + // Sync server-computed fields from the update response + todosCollection.utils.writeBatch(() => { + serverItems.forEach((serverItem) => { + todosCollection.utils.writeUpdate(serverItem) + }) + }) + + return { refetch: false } + }, + }) +) + +// Usage is just like a regular collection +todosCollection.insert({ text: "Buy milk", completed: false }) +``` + +### Example: Large Dataset Pagination + +```typescript +// Load additional pages without refetching existing data +const loadMoreTodos = async (page) => { + const newTodos = await api.getTodos({ page, limit: 50 }) + + // Add new items without affecting existing ones + todosCollection.utils.writeBatch(() => { + newTodos.forEach((todo) => { + todosCollection.utils.writeInsert(todo) + }) + }) +} +``` + +## Important Behaviors + +### Full State Sync + +The query collection treats the `queryFn` result as the **complete state** of the collection. This means: + +- Items present in the collection but not in the query result will be deleted +- Items in the query result but not in the collection will be inserted +- Items present in both will be updated if they differ + +### Empty Array Behavior + +When `queryFn` returns an empty array, **all items in the collection will be deleted**. This is because the collection interprets an empty array as "the server has no items". + +```typescript +// This will delete all items in the collection +queryFn: async () => [] +``` + +### Handling Partial/Incremental Fetches + +Since the query collection expects `queryFn` to return the complete state, you can handle partial fetches by merging new data with existing data: + +```typescript +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: async ({ queryKey }) => { + // Get existing data from cache + const existingData = queryClient.getQueryData(queryKey) || [] + + // Fetch only new/updated items (e.g., changes since last sync) + const lastSyncTime = localStorage.getItem("todos-last-sync") + const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then( + (r) => r.json() + ) + + // Merge new data with existing data + const existingMap = new Map(existingData.map((item) => [item.id, item])) + + // Apply updates and additions + newData.forEach((item) => { + existingMap.set(item.id, item) + }) + + // Handle deletions if your API provides them + if (newData.deletions) { + newData.deletions.forEach((id) => existingMap.delete(id)) + } + + // Update sync time + localStorage.setItem("todos-last-sync", new Date().toISOString()) + + // Return the complete merged state + return Array.from(existingMap.values()) + }, + queryClient, + getKey: (item) => item.id, + }) +) +``` + +This pattern allows you to: + +- Fetch only incremental changes from your API +- Merge those changes with existing data +- Return the complete state that the collection expects +- Avoid the performance overhead of fetching all data every time + +### Direct Writes and Query Sync + +Direct writes update the collection immediately and also update the TanStack Query cache. However, they do not prevent the normal query sync behavior. If your `queryFn` returns data that conflicts with your direct writes, the query data will take precedence. + +To handle this properly: + +1. Use `{ refetch: false }` in your persistence handlers when using direct writes +2. Set appropriate `staleTime` to prevent unnecessary refetches +3. Design your `queryFn` to be aware of incremental updates (e.g., only fetch new data) + +## Complete Direct Write API Reference + +All direct write methods are available on `collection.utils`: + +- `writeInsert(data)`: Insert one or more items directly +- `writeUpdate(data)`: Update one or more items directly +- `writeDelete(keys)`: Delete one or more items directly +- `writeUpsert(data)`: Insert or update one or more items directly +- `writeBatch(callback)`: Perform multiple operations atomically +- `refetch(opts?)`: Manually trigger a refetch of the query + +## QueryFn and Predicate Push-Down + +When using `syncMode: 'on-demand'`, the collection automatically pushes down query predicates (where clauses, orderBy, and limit) to your `queryFn`. This allows you to fetch only the data needed for each specific query, rather than fetching the entire dataset. + +### How LoadSubsetOptions Are Passed + +LoadSubsetOptions are passed to your `queryFn` via the query context's `meta` property: + +```typescript +queryFn: async (ctx) => { + // Extract LoadSubsetOptions from the context + const { limit, where, orderBy } = ctx.meta.loadSubsetOptions + + // Use these to fetch only the data you need + // ... +} +``` + +The `where` and `orderBy` fields are expression trees (AST - Abstract Syntax Tree) that need to be parsed. TanStack DB provides helper functions to make this easy. + +### Expression Helpers + +```typescript +import { + parseWhereExpression, + parseOrderByExpression, + extractSimpleComparisons, + parseLoadSubsetOptions, +} from '@tanstack/db' +// Or from '@tanstack/query-db-collection' (re-exported for convenience) +``` + +These helpers allow you to parse expression trees without manually traversing complex AST structures. + +### Quick Start: Simple REST API + +```typescript +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { parseLoadSubsetOptions } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' + +const queryClient = new QueryClient() + +const productsCollection = createCollection( + queryCollectionOptions({ + id: 'products', + queryKey: ['products'], + queryClient, + getKey: (item) => item.id, + syncMode: 'on-demand', // Enable predicate push-down + + queryFn: async (ctx) => { + const { limit, where, orderBy } = ctx.meta.loadSubsetOptions + + // Parse the expressions into simple format + const parsed = parseLoadSubsetOptions({ where, orderBy, limit }) + + // Build query parameters from parsed filters + const params = new URLSearchParams() + + // Add filters + parsed.filters.forEach(({ field, operator, value }) => { + const fieldName = field.join('.') + if (operator === 'eq') { + params.set(fieldName, String(value)) + } else if (operator === 'lt') { + params.set(`${fieldName}_lt`, String(value)) + } else if (operator === 'gt') { + params.set(`${fieldName}_gt`, String(value)) + } + }) + + // Add sorting + if (parsed.sorts.length > 0) { + const sortParam = parsed.sorts + .map(s => `${s.field.join('.')}:${s.direction}`) + .join(',') + params.set('sort', sortParam) + } + + // Add limit + if (parsed.limit) { + params.set('limit', String(parsed.limit)) + } + + const response = await fetch(`/api/products?${params}`) + return response.json() + }, + }) +) + +// Usage with live queries +import { createLiveQueryCollection } from '@tanstack/react-db' +import { eq, lt, and } from '@tanstack/db' + +const affordableElectronics = createLiveQueryCollection({ + query: (q) => + q.from({ product: productsCollection }) + .where(({ product }) => and( + eq(product.category, 'electronics'), + lt(product.price, 100) + )) + .orderBy(({ product }) => product.price, 'asc') + .limit(10) + .select(({ product }) => product) +}) + +// This triggers a queryFn call with: +// GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 +``` + +### Custom Handlers for Complex APIs + +For APIs with specific formats, use custom handlers: + +```typescript +queryFn: async (ctx) => { + const { where, orderBy, limit } = ctx.meta.loadSubsetOptions + + // Use custom handlers to match your API's format + const filters = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ + field: field.join('.'), + op: 'equals', + value + }), + lt: (field, value) => ({ + field: field.join('.'), + op: 'lessThan', + value + }), + and: (...conditions) => ({ + operator: 'AND', + conditions + }), + or: (...conditions) => ({ + operator: 'OR', + conditions + }), + } + }) + + const sorts = parseOrderByExpression(orderBy) + + return api.query({ + filters, + sort: sorts.map(s => ({ + field: s.field.join('.'), + order: s.direction.toUpperCase() + })), + limit + }) +} +``` + +### GraphQL Example + +```typescript +queryFn: async (ctx) => { + const { where, orderBy, limit } = ctx.meta.loadSubsetOptions + + // Convert to a GraphQL where clause format + const whereClause = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ + [field.join('_')]: { _eq: value } + }), + lt: (field, value) => ({ + [field.join('_')]: { _lt: value } + }), + and: (...conditions) => ({ _and: conditions }), + or: (...conditions) => ({ _or: conditions }), + } + }) + + // Convert to a GraphQL order_by format + const sorts = parseOrderByExpression(orderBy) + const orderByClause = sorts.map(s => ({ + [s.field.join('_')]: s.direction + })) + + const { data } = await graphqlClient.query({ + query: gql` + query GetProducts($where: product_bool_exp, $orderBy: [product_order_by!], $limit: Int) { + product(where: $where, order_by: $orderBy, limit: $limit) { + id + name + category + price + } + } + `, + variables: { + where: whereClause, + orderBy: orderByClause, + limit + } + }) + + return data.product +} +``` + +### Expression Helper API Reference + +#### `parseLoadSubsetOptions(options)` + +Convenience function that parses all LoadSubsetOptions at once. Good for simple use cases. + +```typescript +const { filters, sorts, limit } = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) +// filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }] +// sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }] +// limit: 10 +``` + +#### `parseWhereExpression(expr, options)` + +Parses a WHERE expression using custom handlers for each operator. Use this for complete control over the output format. + +```typescript +const filters = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ [field.join('.')]: value }), + lt: (field, value) => ({ [`${field.join('.')}_lt`]: value }), + and: (...filters) => Object.assign({}, ...filters) + }, + onUnknownOperator: (operator, args) => { + console.warn(`Unsupported operator: ${operator}`) + return null + } +}) +``` + +#### `parseOrderByExpression(orderBy)` + +Parses an ORDER BY expression into a simple array. + +```typescript +const sorts = parseOrderByExpression(orderBy) +// Returns: [{ field: ['price'], direction: 'asc', nulls: 'last' }] +``` + +#### `extractSimpleComparisons(expr)` + +Extracts simple AND-ed comparisons from a WHERE expression. Note: Only works for simple AND conditions. + +```typescript +const comparisons = extractSimpleComparisons(where) +// Returns: [ +// { field: ['category'], operator: 'eq', value: 'electronics' }, +// { field: ['price'], operator: 'lt', value: 100 } +// ] +``` + +### Supported Operators + +- `eq` - Equality (=) +- `gt` - Greater than (>) +- `gte` - Greater than or equal (>=) +- `lt` - Less than (<) +- `lte` - Less than or equal (<=) +- `and` - Logical AND +- `or` - Logical OR +- `in` - IN clause + +### Using Query Key Builders + +Create different cache entries for different filter combinations: + +```typescript +const productsCollection = createCollection( + queryCollectionOptions({ + id: 'products', + // Dynamic query key based on filters + queryKey: (opts) => { + const parsed = parseLoadSubsetOptions(opts) + const cacheKey = ['products'] + + parsed.filters.forEach(f => { + cacheKey.push(`${f.field.join('.')}-${f.operator}-${f.value}`) + }) + + if (parsed.limit) { + cacheKey.push(`limit-${parsed.limit}`) + } + + return cacheKey + }, + queryClient, + getKey: (item) => item.id, + syncMode: 'on-demand', + queryFn: async (ctx) => { /* ... */ }, + }) +) +``` + +### Tips + +1. **Start with `parseLoadSubsetOptions`** for simple use cases +2. **Use custom handlers** via `parseWhereExpression` for APIs with specific formats +3. **Handle unsupported operators** with the `onUnknownOperator` callback +4. **Log parsed results** during development to verify correctness diff --git a/docs/collections/rxdb-collection.md b/docs/collections/rxdb-collection.md new file mode 100644 index 000000000..71ea3ffed --- /dev/null +++ b/docs/collections/rxdb-collection.md @@ -0,0 +1,134 @@ +--- +title: RxDB Collection +--- + +# RxDB Collection + +RxDB collections provide seamless integration between TanStack DB and [RxDB](https://rxdb.info), enabling automatic synchronization between your in-memory TanStack DB collections and RxDB's local-first database. Giving you offline-ready persistence, and powerful sync capabilities with a wide range of backends. + + +## Overview + +The `@tanstack/rxdb-db-collection` package allows you to create collections that: +- Automatically mirror the state of an underlying RxDB collection +- Reactively update when RxDB documents change +- Support optimistic mutations with rollback on error +- Provide persistence handlers to keep RxDB in sync with TanStack DB transactions +- Sync across browser tabs - changes in one tab are reflected in RxDB and TanStack DB collections in all tabs +- Use one of RxDB's [storage engines](https://rxdb.info/rx-storage.html). +- Work with RxDB's [replication features](https://rxdb.info/replication.html) for offline-first and sync scenarios +- Leverage RxDB's [replication plugins](https://rxdb.info/replication.html) to sync with CouchDB, MongoDB, Supabase, REST APIs, GraphQL, WebRTC (P2P) and more. + + +## 1. Installation + +Install the RXDB collection packages along with your preferred framework integration. + +```bash +npm install @tanstack/rxdb-db-collection rxdb @tanstack/react-db +``` + + +### 2. Create an RxDatabase and RxCollection + +```ts +import { createRxDatabase, addRxPlugin } from 'rxdb/plugins/core' + +/** + * Here we use the localstorage based storage for RxDB. + * RxDB has a wide range of storages based on Dexie.js, IndexedDB, SQLite and more. + */ +import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage' + +// add json-schema validation (optional) +import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv'; + +// Enable dev mode (optional, recommended during development) +import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode' +addRxPlugin(RxDBDevModePlugin) + +type Todo = { id: string; text: string; completed: boolean } + +const db = await createRxDatabase({ + name: 'my-todos', + storage: wrappedValidateAjvStorage({ + storage: getRxStorageLocalstorage() + }) +}) + +await db.addCollections({ + todos: { + schema: { + title: 'todos', + version: 0, + type: 'object', + primaryKey: 'id', + properties: { + id: { type: 'string', maxLength: 100 }, + text: { type: 'string' }, + completed: { type: 'boolean' }, + }, + required: ['id', 'text', 'completed'], + }, + }, +}) +``` + + +### 3. (optional) sync with a backend +```ts +import { replicateRxCollection } from 'rxdb/plugins/replication' +const replicationState = replicateRxCollection({ + collection: db.todos, + pull: { handler: myPullHandler }, + push: { handler: myPushHandler }, +}) +``` + +### 4. Wrap the RxDB collection with TanStack DB + +```ts +import { createCollection } from '@tanstack/react-db' +import { rxdbCollectionOptions } from '@tanstack/rxdb-db-collection' + +const todosCollection = createCollection( + rxdbCollectionOptions({ + rxCollection: myDatabase.todos, + startSync: true, // start ingesting RxDB data immediately + }) +) +``` + + +Now `todosCollection` is a reactive TanStack DB collection driven by RxDB: + +- Writes via `todosCollection.insert/update/delete` persist to RxDB. +- Direct writes in RxDB (or via replication) flow into the TanStack collection via change streams. + + + +## Configuration Options + +The `rxdbCollectionOptions` function accepts the following options: + +### Required + +- `rxCollection`: The underlying [RxDB collection](https://rxdb.info/rx-collection.html) + +### Optional + +- `id`: Unique identifier for the collection +- `schema`: Schema for validating items. RxDB already has schema validation but having additional validation on the TanStack DB side can help to unify error handling between different tanstack collections. +- `startSync`: Whether to start syncing immediately (default: true) +- `onInsert, onUpdate, onDelete`: Override default persistence handlers. By default, TanStack DB writes are persisted to RxDB using bulkUpsert, patch, and bulkRemove. +- `syncBatchSize`: The maximum number of documents fetched per batch during the initial sync from RxDB into TanStack DB (default: 1000). Larger values reduce round trips but use more memory; smaller values are lighter but may increase query calls. Note that this only affects the initial sync. Ongoing live updates are streamed one by one via RxDB's change feed. + + + +## Syncing with Backends + +Replication and sync in RxDB run independently of TanStack DB. You set up replication directly on your RxCollection using RxDB's replication plugins (for CouchDB, GraphQL, WebRTC, REST APIs, etc.). + +When replication runs, it pulls and pushes changes to the backend and applies them to the RxDB collection. Since the TanStack DB integration subscribes to the RxDB change stream, any changes applied by replication are automatically reflected in your TanStack DB collection. + +This separation of concerns means you configure replication entirely in RxDB, and TanStack DB automatically benefits: your TanStack collections always stay up to date with whatever sync strategy you choose. 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/community/resources.md b/docs/community/resources.md new file mode 100644 index 000000000..781c11963 --- /dev/null +++ b/docs/community/resources.md @@ -0,0 +1,52 @@ +--- +title: Community Resources +id: community-resources +--- + +# Community Resources + +This page contains a curated list of community-created packages, tools, and resources that extend or complement TanStack DB. + +## Community Packages + +### Dexie.js Integration (Unofficial) +- **[tanstack-dexie-db-collection](https://github.com/HimanshuKumarDutt094/tanstack-dexie-db-collection)** - Community-maintained Dexie.js adapter for TanStack DB + - Local persistence using [Dexie.js](https://dexie.org) (IndexedDB wrapper) + - Lightweight integration for browser-based storage + - Install: `npm install tanstack-dexie-db-collection` + +### PGLite Integration (Unofficial) +- **[tanstack-db-pglite](https://github.com/letstri/tanstack-db-pglite)** - Community-maintained [PGLite](https://pglite.dev/) adapter for TanStack DB + - Use PostgreSQL-compatible databases in the browser via WebAssembly + - Install: `npm install tanstack-db-pglite` + +### Contributing Your Package + +Have you created a collection adapter or integration? We'd love to feature it here! [Submit a PR](https://github.com/TanStack/db/pulls) to add your package. + +## Examples & Templates + +### Starter Templates +*Share your starter templates and boilerplates here* + +### Example Applications +*Community-built example apps showcasing TanStack DB features* + +## Learning Resources + +### Tutorials & Guides +*Community tutorials and blog posts about TanStack DB* + +### Videos & Courses +*Video tutorials and online courses* + +## Contributing + +We welcome contributions to this community resources page! If you've created something useful for the TanStack DB ecosystem: + +1. Fork the repository +2. Add your resource to the appropriate section +3. Include a brief description and link +4. Submit a pull request + +Please ensure all submissions are relevant to TanStack DB and provide value to the community. diff --git a/docs/config.json b/docs/config.json index 515d88d53..09ca7c62b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -20,43 +20,98 @@ { "label": "Installation", "to": "installation" - }, + } + ] + }, + { + "label": "Guides", + "children": [ { "label": "Live Queries", - "to": "live-queries" + "to": "guides/live-queries" + }, + { + "label": "Mutations", + "to": "guides/mutations" + }, + { + "label": "Schemas", + "to": "guides/schemas" + }, + { + "label": "Error Handling", + "to": "guides/error-handling" + }, + { + "label": "Creating Collection Options Creators", + "to": "guides/collection-options-creator" } - ], - "frameworks": [ + ] + }, + { + "label": "Collections", + "children": [ { - "label": "react", - "children": [ - { - "label": "React Adapter", - "to": "framework/react/adapter" - } - ] + "label": "Query Collection", + "to": "collections/query-collection" }, { - "label": "vue", - "children": [ - { - "label": "Vue Adapter", - "to": "framework/vue/adapter" - } - ] + "label": "Electric Collection", + "to": "collections/electric-collection" + }, + { + "label": "TrailBase Collection", + "to": "collections/trailbase-collection" + }, + { + "label": "RxDB Collection", + "to": "collections/rxdb-collection" + }, + { + "label": "PowerSync Collection", + "to": "collections/powersync-collection" + }, + { + "label": "LocalStorage Collection", + "to": "collections/local-storage-collection" + }, + { + "label": "LocalOnly Collection", + "to": "collections/local-only-collection" } ] }, { - "label": "Guides", + "label": "Frameworks", "children": [ { - "label": "Error Handling", - "to": "error-handling" + "label": "React", + "to": "framework/react/overview" }, { - "label": "Creating Collection Options Creators", - "to": "collection-options-creator" + "label": "Vue", + "to": "framework/vue/overview" + }, + { + "label": "Angular", + "to": "framework/angular/overview" + }, + { + "label": "Solid", + "to": "framework/solid/overview" + }, + { + "label": "Svelte", + "to": "framework/svelte/overview" + } + ] + }, + { + "label": "Community", + "children": [ + { + "label": "Resources & Packages", + "to": "community/resources" } ] }, @@ -66,6 +121,62 @@ { "label": "Core API Reference", "to": "reference/index" + }, + { + "label": "Collection", + "to": "reference/interfaces/Collection" + }, + { + "label": "createCollection", + "to": "reference/functions/createCollection" + }, + { + "label": "liveQueryCollectionOptions", + "to": "reference/functions/liveQueryCollectionOptions" + }, + { + "label": "createLiveQueryCollection", + "to": "reference/functions/createLiveQueryCollection" + }, + { + "label": "createOptimisticAction", + "to": "reference/functions/createOptimisticAction" + }, + { + "label": "createTransaction", + "to": "reference/functions/createTransaction" + }, + { + "label": "Electric DB Collection", + "to": "reference/electric-db-collection/index" + }, + { + "label": "electricCollectionOptions", + "to": "reference/electric-db-collection/functions/electricCollectionOptions" + }, + { + "label": "Query DB Collection", + "to": "reference/query-db-collection/index" + }, + { + "label": "queryCollectionOptions", + "to": "reference/query-db-collection/functions/queryCollectionOptions" + }, + { + "label": "RxDB DB Collection", + "to": "reference/rxdb-db-collection/index" + }, + { + "label": "rxdbCollectionOptions", + "to": "reference/rxdb-db-collection/functions/rxdbCollectionOptions" + }, + { + "label": "PowerSync Collection", + "to": "reference/powersync-db-collection/index" + }, + { + "label": "powerSyncCollectionOptions", + "to": "reference/powersync-db-collection/functions/powerSyncCollectionOptions" } ], "frameworks": [ @@ -75,6 +186,23 @@ { "label": "React Hooks", "to": "framework/react/reference/index" + }, + { + "label": "useLiveQuery", + "to": "framework/react/reference/functions/useLiveQuery" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "Solid Hooks", + "to": "framework/solid/reference/index" + }, + { + "label": "useLiveQuery", + "to": "framework/solid/reference/functions/useLiveQuery" } ] }, @@ -82,23 +210,33 @@ "label": "vue", "children": [ { - "label": "Vue Hooks", + "label": "Vue Composables", "to": "framework/vue/reference/index" + }, + { + "label": "useLiveQuery", + "to": "framework/vue/reference/functions/useLiveQuery" + }, + { + "label": "UseLiveQueryReturn", + "to": "framework/vue/reference/interfaces/UseLiveQueryReturn" + }, + { + "label": "UseLiveQueryReturnWithCollection", + "to": "framework/vue/reference/interfaces/UseLiveQueryReturnWithCollection" } ] - } - ] - }, - { - "label": "Examples", - "children": [], - "frameworks": [ + }, { - "label": "react", + "label": "angular", "children": [ { - "label": "Todo List", - "to": "framework/react/examples/todo" + "label": "Angular Functions", + "to": "framework/angular/reference/index" + }, + { + "label": "injectLiveQuery", + "to": "framework/angular/reference/functions/injectLiveQuery" } ] } diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md new file mode 100644 index 000000000..15adcaf6c --- /dev/null +++ b/docs/framework/angular/overview.md @@ -0,0 +1,212 @@ +--- +title: TanStack DB Angular Adapter +id: adapter +--- + +## Installation + +```sh +npm install @tanstack/angular-db +``` + +## Angular inject function + +See the [Angular Functions Reference](../reference/index.md) to see the full list of functions available in the Angular Adapter. + +For comprehensive documentation on writing queries (filtering, joins, aggregations, etc.), see the [Live Queries Guide](../../guides/live-queries). + +## Basic Usage + +### injectLiveQuery + +The `injectLiveQuery` function creates a live query that automatically updates your component when data changes. It returns an object containing Angular signals for reactive state management: + +```typescript +import { Component } from '@angular/core' +import { injectLiveQuery } from '@tanstack/angular-db' +import { eq } from '@tanstack/db' + +@Component({ + selector: 'app-todo-list', + standalone: true, + template: ` + @if (query.isLoading()) { +
Loading...
+ } @else { +
    + @for (todo of query.data(); track todo.id) { +
  • {{ todo.text }}
  • + } +
+ } + ` +}) +export class TodoListComponent { + query = injectLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + ) +} +``` + +**Note:** All return values (`data`, `isLoading`, `status`, etc.) are Angular signals, so call them with `()` in your template: `query.data()`, `query.isLoading()`. + +> **Template Syntax:** Examples use Angular 17+ control flow (`@if`, `@for`). For Angular 16, use `*ngIf` and `*ngFor` instead. + +### Reactive Parameters + +For queries that depend on reactive values, use the `params` option to re-run the query when those values change: + +```typescript +import { Component, signal } from '@angular/core' +import { injectLiveQuery } from '@tanstack/angular-db' +import { gt } from '@tanstack/db' + +@Component({ + selector: 'app-filtered-todos', + standalone: true, + template: ` +
{{ query.data().length }} high-priority todos
+ ` +}) +export class FilteredTodosComponent { + minPriority = signal(5) + + query = injectLiveQuery({ + params: () => ({ minPriority: this.minPriority() }), + query: ({ params, q }) => + q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, params.minPriority)) + }) +} +``` + +#### When to Use Reactive Parameters + +Use the reactive `params` option when your query depends on: +- Component signals +- Input properties +- Computed values +- Other reactive state + +When any reactive value accessed in the `params` function changes, the query is recreated and re-executed. + +#### What Happens When Parameters Change + +When a parameter value changes: +1. The previous live-query collection is disposed +2. A new query is created with the updated parameter values +3. `status()`/`isLoading()` reflect the new query's lifecycle +4. `data()` updates automatically when the new results arrive + +#### Best Practices + +**Use reactive params for dynamic queries:** + +```typescript +import { Component, Input, signal } from '@angular/core' +import { injectLiveQuery } from '@tanstack/angular-db' +import { eq, and } from '@tanstack/db' + +@Component({ + selector: 'app-todo-list', + standalone: true, + template: `
{{ query.data().length }} todos
` +}) +export class TodoListComponent { + // Angular 16+ compatible input + @Input({ required: true }) userId!: number + status = signal('active') + + // Good - reactive params track all dependencies + query = injectLiveQuery({ + params: () => ({ + userId: this.userId, + status: this.status() + }), + query: ({ params, q }) => + q.from({ todos: todosCollection }) + .where(({ todos }) => and( + eq(todos.userId, params.userId), + eq(todos.status, params.status) + )) + }) +} +``` + +**Using Angular 17+ signal inputs:** + +```typescript +import { Component, input, signal } from '@angular/core' +import { injectLiveQuery } from '@tanstack/angular-db' +import { eq, and } from '@tanstack/db' + +@Component({ + selector: 'app-todo-list', + standalone: true, + template: `
{{ query.data().length }} todos
` +}) +export class TodoListComponent { + // Angular 17+ signal-based input + userId = input.required() + status = signal('active') + + query = injectLiveQuery({ + params: () => ({ + userId: this.userId(), + status: this.status() + }), + query: ({ params, q }) => + q.from({ todos: todosCollection }) + .where(({ todos }) => and( + eq(todos.userId, params.userId), + eq(todos.status, params.status) + )) + }) +} +``` + +**Static queries don't need params:** + +```typescript +import { Component } from '@angular/core' +import { injectLiveQuery } from '@tanstack/angular-db' + +@Component({ + selector: 'app-all-todos', + standalone: true, + template: `
{{ query.data().length }} todos
` +}) +export class AllTodosComponent { + // No reactive dependencies - query never changes + query = injectLiveQuery((q) => + q.from({ todos: todosCollection }) + ) +} +``` + +**Access multiple signals in template:** + +```typescript +import { Component } from '@angular/core' +import { injectLiveQuery } from '@tanstack/angular-db' +import { eq } from '@tanstack/db' + +@Component({ + selector: 'app-todos', + standalone: true, + template: ` +
Status: {{ query.status() }}
+
Loading: {{ query.isLoading() }}
+
Ready: {{ query.isReady() }}
+
Total: {{ query.data().length }}
+ ` +}) +export class TodosComponent { + query = injectLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + ) +} +``` diff --git a/docs/framework/angular/reference/functions/injectLiveQuery.md b/docs/framework/angular/reference/functions/injectLiveQuery.md new file mode 100644 index 000000000..4b3209aca --- /dev/null +++ b/docs/framework/angular/reference/functions/injectLiveQuery.md @@ -0,0 +1,120 @@ +--- +id: injectLiveQuery +title: injectLiveQuery +--- + +# Function: injectLiveQuery() + +## Call Signature + +```ts +function injectLiveQuery(options): InjectLiveQueryResult<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }>; +``` + +Defined in: [index.ts:51](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L51) + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +#### TParams + +`TParams` *extends* `unknown` + +### Parameters + +#### options + +##### params + +() => `TParams` + +##### query + +(`args`) => `QueryBuilder`\<`TContext`\> + +### Returns + +[`InjectLiveQueryResult`](../../interfaces/InjectLiveQueryResult.md)\<\{ \[K in string \| number \| symbol\]: (TContext\["result"\] extends object ? any\[any\] : TContext\["hasJoins"\] extends true ? TContext\["schema"\] : TContext\["schema"\]\[TContext\["fromSourceName"\]\])\[K\] \}\> + +## Call Signature + +```ts +function injectLiveQuery(queryFn): InjectLiveQueryResult<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }>; +``` + +Defined in: [index.ts:61](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L61) + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +### Returns + +[`InjectLiveQueryResult`](../../interfaces/InjectLiveQueryResult.md)\<\{ \[K in string \| number \| symbol\]: (TContext\["result"\] extends object ? any\[any\] : TContext\["hasJoins"\] extends true ? TContext\["schema"\] : TContext\["schema"\]\[TContext\["fromSourceName"\]\])\[K\] \}\> + +## Call Signature + +```ts +function injectLiveQuery(config): InjectLiveQueryResult<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }>; +``` + +Defined in: [index.ts:64](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L64) + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +`LiveQueryCollectionConfig`\<`TContext`\> + +### Returns + +[`InjectLiveQueryResult`](../../interfaces/InjectLiveQueryResult.md)\<\{ \[K in string \| number \| symbol\]: (TContext\["result"\] extends object ? any\[any\] : TContext\["hasJoins"\] extends true ? TContext\["schema"\] : TContext\["schema"\]\[TContext\["fromSourceName"\]\])\[K\] \}\> + +## Call Signature + +```ts +function injectLiveQuery(liveQueryCollection): InjectLiveQueryResult; +``` + +Defined in: [index.ts:67](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L67) + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`\> + +### Returns + +[`InjectLiveQueryResult`](../../interfaces/InjectLiveQueryResult.md)\<`TResult`, `TKey`, `TUtils`\> diff --git a/docs/framework/angular/reference/index.md b/docs/framework/angular/reference/index.md new file mode 100644 index 000000000..3dfb77c21 --- /dev/null +++ b/docs/framework/angular/reference/index.md @@ -0,0 +1,14 @@ +--- +id: "@tanstack/angular-db" +title: "@tanstack/angular-db" +--- + +# @tanstack/angular-db + +## Interfaces + +- [InjectLiveQueryResult](../interfaces/InjectLiveQueryResult.md) + +## Functions + +- [injectLiveQuery](../functions/injectLiveQuery.md) diff --git a/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md b/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md new file mode 100644 index 000000000..aa45218bc --- /dev/null +++ b/docs/framework/angular/reference/interfaces/InjectLiveQueryResult.md @@ -0,0 +1,134 @@ +--- +id: InjectLiveQueryResult +title: InjectLiveQueryResult +--- + +# Interface: InjectLiveQueryResult\ + +Defined in: [index.ts:26](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L26) + +The result of calling `injectLiveQuery`. +Contains reactive signals for the query state and data. + +## Type Parameters + +### TResult + +`TResult` *extends* `object` = `any` + +### TKey + +`TKey` *extends* `string` \| `number` = `string` \| `number` + +### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> = \{ +\} + +## Properties + +### collection + +```ts +collection: Signal, TResult>>; +``` + +Defined in: [index.ts:36](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L36) + +A signal containing the underlying collection instance + +*** + +### data + +```ts +data: Signal; +``` + +Defined in: [index.ts:34](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L34) + +A signal containing the results as an array + +*** + +### isCleanedUp + +```ts +isCleanedUp: Signal; +``` + +Defined in: [index.ts:48](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L48) + +A signal indicating whether the collection has been cleaned up + +*** + +### isError + +```ts +isError: Signal; +``` + +Defined in: [index.ts:46](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L46) + +A signal indicating whether the collection has an error + +*** + +### isIdle + +```ts +isIdle: Signal; +``` + +Defined in: [index.ts:44](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L44) + +A signal indicating whether the collection is idle + +*** + +### isLoading + +```ts +isLoading: Signal; +``` + +Defined in: [index.ts:40](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L40) + +A signal indicating whether the collection is currently loading + +*** + +### isReady + +```ts +isReady: Signal; +``` + +Defined in: [index.ts:42](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L42) + +A signal indicating whether the collection is ready + +*** + +### state + +```ts +state: Signal>; +``` + +Defined in: [index.ts:32](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L32) + +A signal containing the complete state map of results keyed by their ID + +*** + +### status + +```ts +status: Signal; +``` + +Defined in: [index.ts:38](https://github.com/TanStack/db/blob/main/packages/angular-db/src/index.ts#L38) + +A signal containing the current status of the collection diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md deleted file mode 100644 index 7a3b17c60..000000000 --- a/docs/framework/react/adapter.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -title: TanStack DB React Adapter -id: adapter ---- - -## Installation - -```sh -npm install @tanstack/react-db -``` - -## React Hooks - -See the [React Functions Reference](../reference/index.md) to see the full list of hooks available in the React Adapter. - -## Basic Usage diff --git a/docs/framework/react/overview.md b/docs/framework/react/overview.md new file mode 100644 index 000000000..0e53916f9 --- /dev/null +++ b/docs/framework/react/overview.md @@ -0,0 +1,163 @@ +--- +title: TanStack DB React Adapter +id: adapter +--- + +## Installation + +```sh +npm install @tanstack/react-db +``` + +## React Hooks + +See the [React Functions Reference](../reference/index.md) to see the full list of hooks available in the React Adapter. + +For comprehensive documentation on writing queries (filtering, joins, aggregations, etc.), see the [Live Queries Guide](../../guides/live-queries). + +## Basic Usage + +### useLiveQuery + +The `useLiveQuery` hook creates a live query that automatically updates your component when data changes: + +```tsx +import { useLiveQuery } from '@tanstack/react-db' + +function TodoList() { + const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + ) + + if (isLoading) return
Loading...
+ + return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+ ) +} +``` + +### Dependency Arrays + +All query hooks (`useLiveQuery`, `useLiveInfiniteQuery`, `useLiveSuspenseQuery`) accept an optional dependency array as their last parameter. This array works similarly to React's `useEffect` dependencies - when any value in the array changes, the query is recreated and re-executed. + +#### When to Use Dependency Arrays + +Use dependency arrays when your query depends on external reactive values (props, state, or other hooks): + +```tsx +function FilteredTodos({ minPriority }: { minPriority: number }) { + const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes + ) + + return
{data.length} high-priority todos
+} +``` + +#### What Happens When Dependencies Change + +When a dependency value changes: +1. The previous live query collection is cleaned up +2. A new query is created with the updated values +3. The component re-renders with the new data +4. The hook suspends (for `useLiveSuspenseQuery`) or shows loading state + +#### Best Practices + +**Include all external values used in the query:** + +```tsx +// Good - all external values in deps +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => and( + eq(todos.userId, userId), + eq(todos.status, status) + )), + [userId, status] +) + +// Bad - missing dependencies +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.userId, userId)), + [] // Missing userId! +) +``` + +**Empty array for static queries:** + +```tsx +// No external dependencies - query never changes +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }), + [] +) +``` + +**Omit the array for queries with no external dependencies:** + +```tsx +// Same as above - no deps needed +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) +) +``` + +### useLiveInfiniteQuery + +For paginated data with live updates, use `useLiveInfiniteQuery`: + +```tsx +const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => q + .from({ posts: postsCollection }) + .where(({ posts }) => eq(posts.category, category)) + .orderBy(({ posts }) => posts.createdAt, 'desc'), + { + pageSize: 20, + getNextPageParam: (lastPage, allPages) => + lastPage.length === 20 ? allPages.length : undefined + }, + [category] // Re-run when category changes +) +``` + +**Note:** The dependency array is only available when using the query function variant, not when passing a pre-created collection. + +### useLiveSuspenseQuery + +For React Suspense integration, use `useLiveSuspenseQuery`: + +```tsx +function TodoList({ filter }: { filter: string }) { + const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.filter, filter)), + [filter] // Re-suspends when filter changes + ) + + return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+ ) +} + +function App() { + return ( + Loading...}> + + + ) +} +``` + +When dependencies change, `useLiveSuspenseQuery` will re-suspend, showing your Suspense fallback until the new data is ready. diff --git a/docs/framework/react/reference/functions/useLiveInfiniteQuery.md b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md new file mode 100644 index 000000000..49f258cf6 --- /dev/null +++ b/docs/framework/react/reference/functions/useLiveInfiniteQuery.md @@ -0,0 +1,217 @@ +--- +id: useLiveInfiniteQuery +title: useLiveInfiniteQuery +--- + +# Function: useLiveInfiniteQuery() + +## Call Signature + +```ts +function useLiveInfiniteQuery(liveQueryCollection, config): UseLiveInfiniteQueryReturn; +``` + +Defined in: [useLiveInfiniteQuery.ts:113](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L113) + +Create an infinite query using a query function with live updates + +Uses `utils.setWindow()` to dynamically adjust the limit/offset window +without recreating the live query collection on each page change. + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +#### config + +[`UseLiveInfiniteQueryConfig`](../../type-aliases/UseLiveInfiniteQueryConfig.md)\<`any`\> + +Configuration including pageSize and getNextPageParam + +### Returns + +[`UseLiveInfiniteQueryReturn`](../../type-aliases/UseLiveInfiniteQueryReturn.md)\<`any`\> + +Object with pages, data, and pagination controls + +### Examples + +```ts +// Basic infinite query +const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, 'desc') + .select(({ posts }) => ({ + id: posts.id, + title: posts.title + })), + { + pageSize: 20, + getNextPageParam: (lastPage, allPages) => + lastPage.length === 20 ? allPages.length : undefined + } +) +``` + +```ts +// With dependencies +const { pages, fetchNextPage } = useLiveInfiniteQuery( + (q) => q + .from({ posts: postsCollection }) + .where(({ posts }) => eq(posts.category, category)) + .orderBy(({ posts }) => posts.createdAt, 'desc'), + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined + }, + [category] +) +``` + +```ts +// Router loader pattern with pre-created collection +// In loader: +const postsQuery = createLiveQueryCollection({ + query: (q) => q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, 'desc') + .limit(20) +}) +await postsQuery.preload() +return { postsQuery } + +// In component: +const { postsQuery } = useLoaderData() +const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + postsQuery, + { + pageSize: 20, + getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined + } +) +``` + +## Call Signature + +```ts +function useLiveInfiniteQuery( + queryFn, + config, +deps?): UseLiveInfiniteQueryReturn; +``` + +Defined in: [useLiveInfiniteQuery.ts:123](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveInfiniteQuery.ts#L123) + +Create an infinite query using a query function with live updates + +Uses `utils.setWindow()` to dynamically adjust the limit/offset window +without recreating the live query collection on each page change. + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work. + +#### config + +[`UseLiveInfiniteQueryConfig`](../../type-aliases/UseLiveInfiniteQueryConfig.md)\<`TContext`\> + +Configuration including pageSize and getNextPageParam + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +[`UseLiveInfiniteQueryReturn`](../../type-aliases/UseLiveInfiniteQueryReturn.md)\<`TContext`\> + +Object with pages, data, and pagination controls + +### Examples + +```ts +// Basic infinite query +const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + (q) => q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, 'desc') + .select(({ posts }) => ({ + id: posts.id, + title: posts.title + })), + { + pageSize: 20, + getNextPageParam: (lastPage, allPages) => + lastPage.length === 20 ? allPages.length : undefined + } +) +``` + +```ts +// With dependencies +const { pages, fetchNextPage } = useLiveInfiniteQuery( + (q) => q + .from({ posts: postsCollection }) + .where(({ posts }) => eq(posts.category, category)) + .orderBy(({ posts }) => posts.createdAt, 'desc'), + { + pageSize: 10, + getNextPageParam: (lastPage) => + lastPage.length === 10 ? lastPage.length : undefined + }, + [category] +) +``` + +```ts +// Router loader pattern with pre-created collection +// In loader: +const postsQuery = createLiveQueryCollection({ + query: (q) => q + .from({ posts: postsCollection }) + .orderBy(({ posts }) => posts.createdAt, 'desc') + .limit(20) +}) +await postsQuery.preload() +return { postsQuery } + +// In component: +const { postsQuery } = useLoaderData() +const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery( + postsQuery, + { + pageSize: 20, + getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined + } +) +``` diff --git a/docs/framework/react/reference/functions/useLiveQuery.md b/docs/framework/react/reference/functions/useLiveQuery.md new file mode 100644 index 000000000..84162b674 --- /dev/null +++ b/docs/framework/react/reference/functions/useLiveQuery.md @@ -0,0 +1,1251 @@ +--- +id: useLiveQuery +title: useLiveQuery +--- + +# Function: useLiveQuery() + +## Call Signature + +```ts +function useLiveQuery(queryFn, deps?): object; +``` + +Defined in: [useLiveQuery.ts:84](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L84) + +Create a live query using a query function + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +Query function that defines what data to fetch + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}>; +``` + +#### data + +```ts +data: InferResultType; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: true; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: Map; +``` + +#### status + +```ts +status: CollectionStatus; +``` + +### Examples + +```ts +// Basic query with object syntax +const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +) +``` + +```ts +// Single result query +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +``` + +```ts +// With dependencies that trigger re-execution +const { data, state } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes +) +``` + +```ts +// Join pattern +const { data } = useLiveQuery((q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +) +``` + +```ts +// Handle loading and error states +const { data, isLoading, isError, status } = useLiveQuery((q) => + q.from({ todos: todoCollection }) +) + +if (isLoading) return
Loading...
+if (isError) return
Error: {status}
+ +return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn, deps?): object; +``` + +Defined in: [useLiveQuery.ts:101](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L101) + +Create a live query using a query function + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> \| `null` \| `undefined` + +Query function that defines what data to fetch + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: + | Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}, StandardSchemaV1, { [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }> + | undefined; +``` + +#### data + +```ts +data: InferResultType | undefined; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: boolean; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: + | Map + | undefined; +``` + +#### status + +```ts +status: UseLiveQueryStatus; +``` + +### Examples + +```ts +// Basic query with object syntax +const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +) +``` + +```ts +// Single result query +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +``` + +```ts +// With dependencies that trigger re-execution +const { data, state } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes +) +``` + +```ts +// Join pattern +const { data } = useLiveQuery((q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +) +``` + +```ts +// Handle loading and error states +const { data, isLoading, isError, status } = useLiveQuery((q) => + q.from({ todos: todoCollection }) +) + +if (isLoading) return
Loading...
+if (isError) return
Error: {status}
+ +return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn, deps?): object; +``` + +Defined in: [useLiveQuery.ts:120](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L120) + +Create a live query using a query function + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => + \| `LiveQueryCollectionConfig`\<`TContext`, \{ \[K in string \| number \| symbol\]: (TContext\["result"\] extends object ? any\[any\] : TContext\["hasJoins"\] extends true ? TContext\["schema"\] : TContext\["schema"\]\[TContext\["fromSourceName"\]\])\[K\] \} & `object`\> + \| `null` + \| `undefined` + +Query function that defines what data to fetch + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: + | Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}, StandardSchemaV1, { [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }> + | undefined; +``` + +#### data + +```ts +data: InferResultType | undefined; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: boolean; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: + | Map + | undefined; +``` + +#### status + +```ts +status: UseLiveQueryStatus; +``` + +### Examples + +```ts +// Basic query with object syntax +const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +) +``` + +```ts +// Single result query +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +``` + +```ts +// With dependencies that trigger re-execution +const { data, state } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes +) +``` + +```ts +// Join pattern +const { data } = useLiveQuery((q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +) +``` + +```ts +// Handle loading and error states +const { data, isLoading, isError, status } = useLiveQuery((q) => + q.from({ todos: todoCollection }) +) + +if (isLoading) return
Loading...
+if (isError) return
Error: {status}
+ +return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn, deps?): object; +``` + +Defined in: [useLiveQuery.ts:139](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L139) + +Create a live query using a query function + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### queryFn + +(`q`) => + \| `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> + \| `null` + \| `undefined` + +Query function that defines what data to fetch + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: + | Collection, TResult> + | undefined; +``` + +#### data + +```ts +data: TResult[] | undefined; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: boolean; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: Map | undefined; +``` + +#### status + +```ts +status: UseLiveQueryStatus; +``` + +### Examples + +```ts +// Basic query with object syntax +const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +) +``` + +```ts +// Single result query +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +``` + +```ts +// With dependencies that trigger re-execution +const { data, state } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes +) +``` + +```ts +// Join pattern +const { data } = useLiveQuery((q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +) +``` + +```ts +// Handle loading and error states +const { data, isLoading, isError, status } = useLiveQuery((q) => + q.from({ todos: todoCollection }) +) + +if (isLoading) return
Loading...
+if (isError) return
Error: {status}
+ +return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+) +``` + +## Call Signature + +```ts +function useLiveQuery(queryFn, deps?): object; +``` + +Defined in: [useLiveQuery.ts:162](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L162) + +Create a live query using a query function + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### queryFn + +(`q`) => + \| `QueryBuilder`\<`TContext`\> + \| `LiveQueryCollectionConfig`\<`TContext`, \{ \[K in string \| number \| symbol\]: (TContext\["result"\] extends object ? any\[any\] : TContext\["hasJoins"\] extends true ? TContext\["schema"\] : TContext\["schema"\]\[TContext\["fromSourceName"\]\])\[K\] \} & `object`\> + \| `Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> + \| `null` + \| `undefined` + +Query function that defines what data to fetch + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: + | Collection, TResult> + | Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}, StandardSchemaV1, { [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }> + | undefined; +``` + +#### data + +```ts +data: InferResultType | TResult[] | undefined; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: boolean; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: + | Map + | Map + | undefined; +``` + +#### status + +```ts +status: UseLiveQueryStatus; +``` + +### Examples + +```ts +// Basic query with object syntax +const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +) +``` + +```ts +// Single result query +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +``` + +```ts +// With dependencies that trigger re-execution +const { data, state } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes +) +``` + +```ts +// Join pattern +const { data } = useLiveQuery((q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +) +``` + +```ts +// Handle loading and error states +const { data, isLoading, isError, status } = useLiveQuery((q) => + q.from({ todos: todoCollection }) +) + +if (isLoading) return
Loading...
+if (isError) return
Error: {status}
+ +return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+) +``` + +## Call Signature + +```ts +function useLiveQuery(config, deps?): object; +``` + +Defined in: [useLiveQuery.ts:230](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L230) + +Create a live query using configuration object + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +`LiveQueryCollectionConfig`\<`TContext`\> + +Configuration object with query and options + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}>; +``` + +#### data + +```ts +data: InferResultType; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: true; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: Map; +``` + +#### status + +```ts +status: CollectionStatus; +``` + +### Examples + +```ts +// Basic config object usage +const { data, status } = useLiveQuery({ + query: (q) => q.from({ todos: todosCollection }), + gcTime: 60000 +}) +``` + +```ts +// With query builder and options +const queryBuilder = new Query() + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ id: persons.id, name: persons.name })) + +const { data, isReady } = useLiveQuery({ query: queryBuilder }) +``` + +```ts +// Handle all states uniformly +const { data, isLoading, isReady, isError } = useLiveQuery({ + query: (q) => q.from({ items: itemCollection }) +}) + +if (isLoading) return
Loading...
+if (isError) return
Something went wrong
+if (!isReady) return
Preparing...
+ +return
{data.length} items loaded
+``` + +## Call Signature + +```ts +function useLiveQuery(liveQueryCollection): object; +``` + +Defined in: [useLiveQuery.ts:276](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L276) + +Subscribe to an existing live query collection + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +Pre-created live query collection to subscribe to + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: Collection; +``` + +#### data + +```ts +data: TResult[]; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: true; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: Map; +``` + +#### status + +```ts +status: CollectionStatus; +``` + +### Examples + +```ts +// Using pre-created live query collection +const myLiveQuery = createLiveQueryCollection((q) => + q.from({ todos: todosCollection }).where(({ todos }) => eq(todos.active, true)) +) +const { data, collection } = useLiveQuery(myLiveQuery) +``` + +```ts +// Access collection methods directly +const { data, collection, isReady } = useLiveQuery(existingCollection) + +// Use collection for mutations +const handleToggle = (id) => { + collection.update(id, draft => { draft.completed = !draft.completed }) +} +``` + +```ts +// Handle states consistently +const { data, isLoading, isError } = useLiveQuery(sharedCollection) + +if (isLoading) return
Loading...
+if (isError) return
Error loading data
+ +return
{data.map(item => )}
+``` + +## Call Signature + +```ts +function useLiveQuery(liveQueryCollection): object; +``` + +Defined in: [useLiveQuery.ts:296](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveQuery.ts#L296) + +Create a live query using a query function + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `SingleResult` + +### Returns + +`object` + +Object with reactive data, state, and status information + +#### collection + +```ts +collection: Collection, TResult> & SingleResult; +``` + +#### data + +```ts +data: TResult | undefined; +``` + +#### isCleanedUp + +```ts +isCleanedUp: boolean; +``` + +#### isEnabled + +```ts +isEnabled: true; +``` + +#### isError + +```ts +isError: boolean; +``` + +#### isIdle + +```ts +isIdle: boolean; +``` + +#### isLoading + +```ts +isLoading: boolean; +``` + +#### isReady + +```ts +isReady: boolean; +``` + +#### state + +```ts +state: Map; +``` + +#### status + +```ts +status: CollectionStatus; +``` + +### Examples + +```ts +// Basic query with object syntax +const { data, isLoading } = useLiveQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) +) +``` + +```ts +// Single result query +const { data } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +``` + +```ts +// With dependencies that trigger re-execution +const { data, state } = useLiveQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-run when minPriority changes +) +``` + +```ts +// Join pattern +const { data } = useLiveQuery((q) => + q.from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + userName: persons.name + })) +) +``` + +```ts +// Handle loading and error states +const { data, isLoading, isError, status } = useLiveQuery((q) => + q.from({ todos: todoCollection }) +) + +if (isLoading) return
Loading...
+if (isError) return
Error: {status}
+ +return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+) +``` diff --git a/docs/framework/react/reference/functions/useLiveSuspenseQuery.md b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md new file mode 100644 index 000000000..679fb98fd --- /dev/null +++ b/docs/framework/react/reference/functions/useLiveSuspenseQuery.md @@ -0,0 +1,490 @@ +--- +id: useLiveSuspenseQuery +title: useLiveSuspenseQuery +--- + +# Function: useLiveSuspenseQuery() + +## Call Signature + +```ts +function useLiveSuspenseQuery(queryFn, deps?): object; +``` + +Defined in: [useLiveSuspenseQuery.ts:76](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L76) + +Create a live query with React Suspense support + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### queryFn + +(`q`) => `QueryBuilder`\<`TContext`\> + +Query function that defines what data to fetch + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data and state - data is guaranteed to be defined + +#### collection + +```ts +collection: Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}>; +``` + +#### data + +```ts +data: InferResultType; +``` + +#### state + +```ts +state: Map; +``` + +### Throws + +Promise when data is loading (caught by Suspense boundary) + +### Throws + +Error when collection fails (caught by Error boundary) + +### Examples + +```ts +// Basic usage with Suspense +function TodoList() { + const { data } = useLiveSuspenseQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + ) + + return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+ ) +} + +function App() { + return ( + Loading...}> + + + ) +} +``` + +```ts +// Single result query +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +// data is guaranteed to be the single item (or undefined if not found) +``` + +```ts +// With dependencies that trigger re-suspension +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-suspends when minPriority changes +) +``` + +```ts +// With Error boundary +function App() { + return ( + Error loading data}> + Loading...}> + + + + ) +} +``` + +## Call Signature + +```ts +function useLiveSuspenseQuery(config, deps?): object; +``` + +Defined in: [useLiveSuspenseQuery.ts:86](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L86) + +Create a live query with React Suspense support + +### Type Parameters + +#### TContext + +`TContext` *extends* `Context` + +### Parameters + +#### config + +`LiveQueryCollectionConfig`\<`TContext`\> + +#### deps? + +`unknown`[] + +Array of dependencies that trigger query re-execution when changed + +### Returns + +`object` + +Object with reactive data and state - data is guaranteed to be defined + +#### collection + +```ts +collection: Collection<{ [K in string | number | symbol]: (TContext["result"] extends object ? any[any] : TContext["hasJoins"] extends true ? TContext["schema"] : TContext["schema"][TContext["fromSourceName"]])[K] }, string | number, { +}>; +``` + +#### data + +```ts +data: InferResultType; +``` + +#### state + +```ts +state: Map; +``` + +### Throws + +Promise when data is loading (caught by Suspense boundary) + +### Throws + +Error when collection fails (caught by Error boundary) + +### Examples + +```ts +// Basic usage with Suspense +function TodoList() { + const { data } = useLiveSuspenseQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + ) + + return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+ ) +} + +function App() { + return ( + Loading...}> + + + ) +} +``` + +```ts +// Single result query +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +// data is guaranteed to be the single item (or undefined if not found) +``` + +```ts +// With dependencies that trigger re-suspension +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-suspends when minPriority changes +) +``` + +```ts +// With Error boundary +function App() { + return ( + Error loading data}> + Loading...}> + + + + ) +} +``` + +## Call Signature + +```ts +function useLiveSuspenseQuery(liveQueryCollection): object; +``` + +Defined in: [useLiveSuspenseQuery.ts:96](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L96) + +Create a live query with React Suspense support + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `NonSingleResult` + +### Returns + +`object` + +Object with reactive data and state - data is guaranteed to be defined + +#### collection + +```ts +collection: Collection; +``` + +#### data + +```ts +data: TResult[]; +``` + +#### state + +```ts +state: Map; +``` + +### Throws + +Promise when data is loading (caught by Suspense boundary) + +### Throws + +Error when collection fails (caught by Error boundary) + +### Examples + +```ts +// Basic usage with Suspense +function TodoList() { + const { data } = useLiveSuspenseQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + ) + + return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+ ) +} + +function App() { + return ( + Loading...}> + + + ) +} +``` + +```ts +// Single result query +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +// data is guaranteed to be the single item (or undefined if not found) +``` + +```ts +// With dependencies that trigger re-suspension +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-suspends when minPriority changes +) +``` + +```ts +// With Error boundary +function App() { + return ( + Error loading data}> + Loading...}> + + + + ) +} +``` + +## Call Signature + +```ts +function useLiveSuspenseQuery(liveQueryCollection): object; +``` + +Defined in: [useLiveSuspenseQuery.ts:109](https://github.com/TanStack/db/blob/main/packages/react-db/src/useLiveSuspenseQuery.ts#L109) + +Create a live query with React Suspense support + +### Type Parameters + +#### TResult + +`TResult` *extends* `object` + +#### TKey + +`TKey` *extends* `string` \| `number` + +#### TUtils + +`TUtils` *extends* `Record`\<`string`, `any`\> + +### Parameters + +#### liveQueryCollection + +`Collection`\<`TResult`, `TKey`, `TUtils`, `StandardSchemaV1`\<`unknown`, `unknown`\>, `TResult`\> & `SingleResult` + +### Returns + +`object` + +Object with reactive data and state - data is guaranteed to be defined + +#### collection + +```ts +collection: Collection, TResult> & SingleResult; +``` + +#### data + +```ts +data: TResult | undefined; +``` + +#### state + +```ts +state: Map; +``` + +### Throws + +Promise when data is loading (caught by Suspense boundary) + +### Throws + +Error when collection fails (caught by Error boundary) + +### Examples + +```ts +// Basic usage with Suspense +function TodoList() { + const { data } = useLiveSuspenseQuery((q) => + q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.completed, false)) + .select(({ todos }) => ({ id: todos.id, text: todos.text })) + ) + + return ( +
    + {data.map(todo =>
  • {todo.text}
  • )} +
+ ) +} + +function App() { + return ( + Loading...}> + + + ) +} +``` + +```ts +// Single result query +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => eq(todos.id, 1)) + .findOne() +) +// data is guaranteed to be the single item (or undefined if not found) +``` + +```ts +// With dependencies that trigger re-suspension +const { data } = useLiveSuspenseQuery( + (q) => q.from({ todos: todosCollection }) + .where(({ todos }) => gt(todos.priority, minPriority)), + [minPriority] // Re-suspends when minPriority changes +) +``` + +```ts +// With Error boundary +function App() { + return ( + Error loading data}> + Loading...}> + + + + ) +} +``` diff --git a/docs/framework/react/reference/functions/usePacedMutations.md b/docs/framework/react/reference/functions/usePacedMutations.md new file mode 100644 index 000000000..4f50b0411 --- /dev/null +++ b/docs/framework/react/reference/functions/usePacedMutations.md @@ -0,0 +1,131 @@ +--- +id: usePacedMutations +title: usePacedMutations +--- + +# Function: usePacedMutations() + +```ts +function usePacedMutations(config): (variables) => Transaction; +``` + +Defined in: [usePacedMutations.ts:93](https://github.com/TanStack/db/blob/main/packages/react-db/src/usePacedMutations.ts#L93) + +React hook for managing paced mutations with timing strategies. + +Provides optimistic mutations with pluggable strategies like debouncing, +queuing, or throttling. The optimistic updates are applied immediately via +`onMutate`, and the actual persistence is controlled by the strategy. + +## Type Parameters + +### TVariables + +`TVariables` = `unknown` + +### T + +`T` *extends* `object` = `Record`\<`string`, `unknown`\> + +## Parameters + +### config + +`PacedMutationsConfig`\<`TVariables`, `T`\> + +Configuration including onMutate, mutationFn and strategy + +## Returns + +A mutate function that accepts variables and returns Transaction objects + +```ts +(variables): Transaction; +``` + +### Parameters + +#### variables + +`TVariables` + +### Returns + +`Transaction`\<`T`\> + +## Examples + +```tsx +// Debounced auto-save +function AutoSaveForm({ formId }: { formId: string }) { + const mutate = usePacedMutations({ + onMutate: (value) => { + // Apply optimistic update immediately + formCollection.update(formId, draft => { + draft.content = value + }) + }, + mutationFn: async ({ transaction }) => { + await api.save(transaction.mutations) + }, + strategy: debounceStrategy({ wait: 500 }) + }) + + const handleChange = async (value: string) => { + const tx = mutate(value) + + // Optional: await persistence or handle errors + try { + await tx.isPersisted.promise + console.log('Saved!') + } catch (error) { + console.error('Save failed:', error) + } + } + + return