From 0b76918492f68b93841900179b900d94868fb40a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Oct 2026 13:28:35 -0600 Subject: [PATCH 1/5] docs: correct TanStack DB blog examples and sync claims --- ...dded-client-database-for-tanstack-query.md | 49 ++++-- src/blog/tanstack-db-0.5-query-driven-sync.md | 143 +++++++++++------- ...app-ready-with-persistence-and-includes.md | 38 +++-- 3 files changed, 148 insertions(+), 82 deletions(-) diff --git a/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md b/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md index eb838ea64..c98cacbbc 100644 --- a/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md +++ b/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md @@ -1,6 +1,7 @@ --- title: Stop Re-Rendering. TanStack DB, the Embedded Client Database for TanStack Query published: 2025-07-30 +updated: 2026-10-13 excerpt: Your React dashboard shouldn't grind to a halt because one TODO gets checked. TanStack DB is a client-side database powered by differential dataflow that plugs into your existing useQuery calls. library: db authors: @@ -191,9 +192,13 @@ const Todos = () => { // ✅ Define a Query Collection import { createCollection } from '@tanstack/react-db' import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { QueryClient } from '@tanstack/query-core' + +const queryClient = new QueryClient() const todoCollection = createCollection( queryCollectionOptions({ + queryClient, queryKey: ['todos'], queryFn: async () => api.todos.getAll('/api/todos'), @@ -289,18 +294,36 @@ You can do this: ```typescript // Load normalized collections upfront (3 broader calls) -const todoCollection = createQueryCollection({ - queryKey: ['todos'], - queryFn: fetchAllTodos, -}) -const userCollection = createQueryCollection({ - queryKey: ['users'], - queryFn: fetchAllUsers, -}) -const projectCollection = createQueryCollection({ - queryKey: ['projects'], - queryFn: fetchAllProjects, -}) +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { QueryClient } from '@tanstack/query-core' + +const queryClient = new QueryClient() + +const todoCollection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ['todos'], + queryFn: fetchAllTodos, + getKey: (todo) => todo.id, + }), +) +const userCollection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ['users'], + queryFn: fetchAllUsers, + getKey: (user) => user.id, + }), +) +const projectCollection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ['projects'], + queryFn: fetchAllProjects, + getKey: (project) => project.id, + }), +) // Navigation is instant, no new API calls needed const { data: activeProjectTodos } = useLiveQuery( @@ -346,7 +369,7 @@ While TanStack DB works great with REST and GraphQL, it really shines when paire **Load far more data efficiently**: It's far cheaper to update data in the client when using sync engines. Instead of re-loading entire collections after each change, sync engines send only the actual changed items. This makes it practical to load far more data upfront, enabling the "load everything once" pattern that makes apps like Linear feel so fast. -TanStack DB was designed from the ground up to support sync engines. [When you define a collection, you're provided with an API for writing synced transactions](https://tanstack.com/db/latest/docs/guides/collection-options-creator) from the backend into your local collections. Try out collection implementations for [Electric](https://tanstack.com/db/latest/docs/installation#electric-collection), [Trailblaze](https://tanstack.com/db/latest/docs/installation#trailbase-collection), and [(soon) Firebase](https://github.com/TanStack/db/pull/323)! +TanStack DB was designed from the ground up to support sync engines. [When you define a collection, you're provided with an API for writing synced transactions](https://tanstack.com/db/latest/docs/guides/collection-options-creator) from the backend into your local collections. Try the current collection implementations for [Electric](https://tanstack.com/db/latest/docs/installation#electric-collection), [TrailBase](https://tanstack.com/db/latest/docs/installation#trailbase-collection), and [PowerSync](https://tanstack.com/db/latest/docs/installation#powersync-collection). DB gives you a common interface for your components to query data, which means you can easily switch between data loading strategies as needed without changing client code. Start with REST, switch to a sync engine later as needed, your components don't need to know the difference. diff --git a/src/blog/tanstack-db-0.5-query-driven-sync.md b/src/blog/tanstack-db-0.5-query-driven-sync.md index bb60765c8..a1beb5d14 100644 --- a/src/blog/tanstack-db-0.5-query-driven-sync.md +++ b/src/blog/tanstack-db-0.5-query-driven-sync.md @@ -1,7 +1,8 @@ --- -title: TanStack DB 0.5 . Query-Driven Sync +title: TanStack DB 0.5: Query-Driven Sync published: 2025-11-12 -excerpt: Your component's query is now the API call. No custom endpoint, no GraphQL resolver, no backend change. TanStack DB 0.5 introduces query-driven sync that eliminates the glue code between your UI and your data. +updated: 2026-10-13 +excerpt: Your component's query drives loading through your existing API. TanStack DB 0.5 introduces query-driven sync that reduces the glue code between your UI and your data. library: db authors: - Sam Willis @@ -11,24 +12,26 @@ authors: ![Query-Driven Sync](/blog-assets/tanstack-db-0.5-query-driven-sync/header.png) -You don't need a new API for every component. With 0.5, the component's query _is_ the API call. +You don't need a new API for every component. With 0.5, the component's query can drive what your collection requests from the API. ```tsx // Your component's query... const { data: projectTodos } = useLiveQuery((q) => q .from({ todos }) - .join({ projects }, (t, p) => eq(t.projectId, p.id)) + .join({ projects }, ({ todos, projects }) => + eq(todos.projectId, projects.id), + ) .where(({ todos }) => eq(todos.status, 'active')) .where(({ projects }) => eq(projects.id, 123)), ) -// ...becomes these precise API calls automatically: +// With Query Collections that map these predicates, this can become: // GET /api/projects/123 // GET /api/todos?projectId=123&status=active ``` -No custom endpoint. No GraphQL resolver. No backend change. Just write your query and TanStack DB figures out exactly what to fetch. +If your existing API accepts these filters, you don't need a new endpoint or GraphQL resolver. Configure each collection's `queryFn` to map the requested predicates to that API. We're releasing TanStack DB 0.5 today with Query-Driven Sync. A feature that fundamentally changes how you think about loading data. @@ -106,9 +109,15 @@ Let's see how each works. With 0.5, you add one line to your collection: ```tsx +import { QueryClient } from '@tanstack/query-core' + +const queryClient = new QueryClient() + const productsCollection = createCollection( queryCollectionOptions({ + queryClient, queryKey: ['products'], + getKey: (product) => product.id, queryFn: async (ctx) => { // Parse your query predicates into API parameters const params = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) @@ -135,7 +144,7 @@ const { data: electronics } = useLiveQuery((q) => ) ``` -TanStack DB automatically calls your `queryFn` with: +For an API that supports these filters, your `queryFn` can turn the requested subset into: ``` GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 @@ -143,7 +152,7 @@ GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 **No custom API endpoint.** **No GraphQL schema changes.** Just a general-purpose products API that accepts filter parameters. -Your component's query becomes the API call. +Your component's query guides the API request. If you're familiar with Relay or Apollo, this should feel familiar: components declare their data needs, and the framework optimizes fetching and updates. The difference? You get Relay-style normalized caching and automatic updates without GraphQL. Your REST, GraphQL, or tRPC API stays simple, your queries stay powerful, and differential dataflow keeps everything fast client-side. @@ -151,11 +160,11 @@ If you're familiar with Relay or Apollo, this should feel familiar: components d "Wait," you're thinking, "doesn't this create N+1 query problems?" -No. And here's why the performance story is actually _better_ than custom APIs. +Query Collection can share requests, while the number of network calls still depends on your collection adapter and API. ### **Automatic request collapsing** -Multiple components requesting the same data trigger exactly one network call: +Components requesting the same subset through one Query Collection can share a single in-flight fetch: ```tsx // Component A @@ -168,45 +177,43 @@ const { data: active } = useLiveQuery((q) => q.from({ todos }).where(({ todos }) => eq(todos.status, 'active')), ) -// Result: ONE network request +// With one Query Collection, both components can share this in-flight request: // GET /api/todos?status=active ``` -TanStack DB compares predicates across all live queries and deduplicates requests automatically. +Query Collection uses the requested subset in its TanStack Query cache key, so identical requests can share work. Custom collection adapters control their own transport deduplication; they can use `DeduplicatedLoadSubset` when they need it. ### **Subset matching and delta loading** -When you navigate from viewing 10 products to viewing 20, DB doesn't reload everything: +For an on-demand Query Collection backed by an offset-based API, an ordered infinite query can load more rows: ```tsx -// Initial query: loads 10 products -const { data } = useLiveQuery((q) => q.from({ products }).limit(10)) +const { data, fetchNextPage } = useLiveInfiniteQuery( + (q) => + q + .from({ product: productsCollection }) + .orderBy(({ product }) => product.id, 'asc'), + { pageSize: 10 }, +) -// User clicks "load more": loads ONLY the next 10 +// In the "load more" button handler: fetchNextPage() ``` -``` -# Page 1 -GET /api/products?limit=10&offset=0 - -# Page 2 -GET /api/products?limit=10&offset=10 -# NOT: GET /api/products?limit=20 -``` - -Already-loaded rows are reused; only the new window crosses the wire. The collection tracks which predicates it has already satisfied and only fetches the delta. +The hook widens the ordered query window and may request an extra row to determine whether another page exists. The `queryFn` must fulfill the requested filter, order, limit, and offset; a request is not necessarily one UI page. A different filter, order, or limit has a distinct request identity and may fetch rows that are already present locally. See the [server pagination guide](/db/latest/docs/collections/query-collection#server-pagination-with-live-queries) for the full contract. ### **Join optimization** -Complex joins don't cause request explosions. They trigger a minimal set of filtered requests: +When an adapter maps pushed-down predicates to batched API filters, a join can request related rows together: ```tsx // Join todos with their projects const { data } = useLiveQuery((q) => q .from({ todos }) - .join({ projects }, (t, p) => eq(t.projectId, p.id)) + .join({ projects }, ({ todos, projects }) => + eq(todos.projectId, projects.id), + ) .where(({ todos }) => eq(todos.status, 'active')), ) @@ -217,33 +224,51 @@ const { data } = useLiveQuery((q) => // NOT 10 separate project requests! ``` -DB analyzes the join to determine exactly which related records are needed, then fetches them in a single batched request. +DB derives the related keys. The collection's `queryFn` determines how those keys become transport requests, including whether the API can fetch them in one batch. ### **Respects your cache policies** Query Collection integrates with TanStack Query's `staleTime` and `gcTime`: ```tsx -const productsCollection = createCollection(queryCollectionOptions({ - queryKey: ['products'], - queryFn: fetchProducts, - staleTime: 5 * 60 * 1000, // 5 minutes - syncMode: 'on-demand' -})) +const productsCollection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ['products'], + queryFn: fetchProducts, + getKey: (product) => product.id, + staleTime: 5 * 60 * 1000, // 5 minutes + syncMode: 'on-demand', + }), +) // First query: network request -useLiveQuery(q => q.from({ products }).where(...)) +useLiveQuery((q) => + q + .from({ product: productsCollection }) + .where(({ product }) => eq(product.category, 'hardware')), +) -// Same query within 5 minutes: instant, no network -useLiveQuery(q => q.from({ products }).where(...)) +// Identical request within 5 minutes: reuse the cached result +useLiveQuery((q) => + q + .from({ product: productsCollection }) + .where(({ product }) => eq(product.category, 'hardware')), +) -// Different query within 5 minutes: only fetches the diff -useLiveQuery(q => q.from({ products }).where(...).limit(20)) +// Different order/limit: a distinct request that may fetch overlapping rows +useLiveQuery((q) => + q + .from({ product: productsCollection }) + .where(({ product }) => eq(product.category, 'hardware')) + .orderBy(({ product }) => product.id, 'asc') + .limit(20), +) ``` -You get TanStack Query's sophisticated caching plus DB's intelligent subset tracking. +You get TanStack Query's caching for each requested subset. -**The result:** Fewer total network requests than custom view-specific APIs, with better cache utilization and zero endpoint sprawl. +**The result:** Shared requests and subset caching can reduce network work without adding a view-specific endpoint for every component. ## Progressive sync: Fast initial paint + instant client queries @@ -254,7 +279,8 @@ That's progressive mode: load what you need immediately, sync everything in the ```tsx const todoCollection = createCollection( electricCollectionOptions({ - table: 'todos', + shapeOptions: { url: '/api/todos' }, + getKey: (todo) => todo.id, syncMode: 'progressive', }), ) @@ -265,17 +291,17 @@ const { data: urgentTodos } = useLiveQuery((q) => .from({ todos: todoCollection }) .where(({ todos }) => eq(todos.priority, 'urgent')), ) -// ~100ms: Network request for urgent todos only +// The first query can load the urgent subset before full sync completes. // Meanwhile, collection syncs full dataset in background -// After sync completes: all queries run in <1ms client-side +// After sync completes: subsequent queries can run against local data. ``` -Now your first query loads in ~100ms with a targeted network request. While the user interacts with that data, the full dataset syncs in the background. Once complete, all subsequent queries (even complex joins and filters) run in sub-millisecond time purely client-side. +The first query can use a targeted network request while the full dataset syncs in the background. Once that sync completes, subsequent queries can use the local data. Actual latency depends on the API, network, and query. -**Progressive mode shines with sync engines** like Electric, Trailbase, and PowerSync. With traditional fetch approaches, loading more data means re-fetching everything, which gets expensive fast. But sync engines only send deltas (the actual changed rows), making it cheap to maintain large client-side datasets. You get instant queries over 10,000s of rows without the network cost of repeatedly fetching all that data. +**Progressive mode works with Query Collection and any collection adapter that supports subset loading plus full background sync.** The Electric example above is one way to use it. Sync engines like Electric, Trailbase, and PowerSync can stream changed rows after the initial load, which can make a full local dataset economical to maintain. -With REST APIs, progressive mode is less common since updates generally require full re-fetches. But for sync engines, it's often the sweet spot: fast first paint + instant everything else. +With a REST API, the choice depends on the cost of fetching and refreshing the full dataset. You can use the same progressive loading pattern when that cost fits your application. ## Works today with REST. Gets better with sync engines. @@ -284,10 +310,10 @@ Query-Driven Sync is designed to work with your existing REST, GraphQL, or tRPC For teams using sync engines like [Electric](https://electric-sql.com), [Trailbase](https://trailbase.io/), or [PowerSync](https://www.powersync.com/), you get additional benefits: - **Real-time updates** via streaming (no polling required) -- **Automatic predicate translation** (no manual mapping needed) +- **Adapter-specific predicate translation** for supported queries - **Delta-only syncing** (only changed rows cross the wire) -For example, Electric translates your client query directly into Postgres queries, applies authorization rules, and streams updates. Your component's query becomes a secure, real-time, authorized Postgres query, no API endpoint needed. +For example, the Electric collection adapter translates supported subset predicates into shape requests and streams updates. Configure your Electric proxy to authenticate and authorize those requests before serving data. Collections abstract the data source. Start with REST. Upgrade to sync when you need real-time. @@ -300,7 +326,7 @@ We provide helper functions to make this straightforward: ```tsx queryFn: async (ctx) => { // Parse expression trees into a simple format - const { filters, sorts, limit } = parseLoadSubsetOptions( + const { filters, sorts, limit, offset } = parseLoadSubsetOptions( ctx.meta?.loadSubsetOptions, ) @@ -312,7 +338,16 @@ queryFn: async (ctx) => { params.set(`${field.join('.')}_lt`, String(value)) // Map other operators as needed }) - if (limit) params.set('limit', String(limit)) + if (sorts.length > 0) { + params.set( + 'sort', + sorts + .map(({ field, direction }) => `${field.join('.')}:${direction}`) + .join(','), + ) + } + if (limit !== undefined) params.set('limit', String(limit)) + if (offset !== undefined) params.set('offset', String(offset)) return fetch(`/api/products?${params}`).then((r) => r.json()) } @@ -322,7 +357,7 @@ For APIs with custom formats (like GraphQL), use `parseWhereExpression` with cus ```tsx queryFn: async (ctx) => { - const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions + const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} // Map to GraphQL's where clause format const whereClause = parseWhereExpression(where, { @@ -347,6 +382,8 @@ You write this mapping once per collection. After that, every query automaticall Query-Driven Sync (0.5) completes the core vision: intelligent loading that adapts to your queries, instant client-side updates via differential dataflow, and seamless persistence back to your backend. We're targeting 1.0 for December 2025, focusing on API stability and comprehensive docs. +> **Timeline update (October 2026):** December 2025 was our target when this post was published. TanStack DB is still in beta; see the [current DB documentation](/db/latest/docs/overview) for its present status. + **This is new. We need early adopters.** Query-Driven Sync works and ships today, but it's fresh. If you try it, we'd love your feedback on rough edges or API improvements. Join us in [Discord](https://discord.gg/tanstack) or open [GitHub issues](https://github.com/TanStack/db/issues). If you have ideas for new collection types based on Query-Driven Sync, please reach out. The interface is very powerful and we have lots of interesting ideas for how it can be used. diff --git a/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md b/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md index 4972b9ce7..652787c17 100644 --- a/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md +++ b/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md @@ -1,7 +1,8 @@ --- title: TanStack DB 0.6 Now Includes Persistence, Offline Support, and Hierarchical Data -excerpt: TanStack DB 0.6 adds SQLite-backed persistence across runtimes, hierarchical includes for projecting normalized data into UI-shaped trees, reactive effects, virtual props for sync state, and more. +excerpt: TanStack DB 0.6 adds SQLite-backed persistence across runtimes, hierarchical includes for projecting normalized data into UI-shaped trees, reactive effects, virtual props for pending local writes, and more. published: 2026-03-25 +updated: 2026-10-13 draft: false library: db authors: @@ -13,14 +14,14 @@ authors: With v0.6 of TanStack DB, we're bringing some highly anticipated features and improving the ergonomics for app development. -You can now project normalized data into the same hierarchical shape as your UI, persist local state across runtimes with a SQLite-backed layer, and trigger reactive side effects directly from live queries. Row metadata unlocks outbox views and delivery indicators (think WhatsApp-style), and APIs that previously relied on implicit behavior are now explicit and consistent. +You can now project normalized data into the same hierarchical shape as your UI, persist local state across runtimes with a SQLite-backed layer, and trigger reactive side effects directly from live queries. Row metadata makes pending local writes and collection attribution queryable, and APIs that previously relied on implicit behavior are now explicit and consistent. Here’s what shipped: - [Persistent local state](#persistent-local-state) via SQLite-backed adapters across browser, React Native, Expo, Node, Electron, Capacitor, Tauri, and Cloudflare Durable Objects - [Includes](#includes-project-your-data-into-the-same-shape-as-your-ui) for projecting normalized data into the hierarchical structure of your UI. Similar to GraphQL, but without the need for new infrastructure. - [`createEffect`](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) for reactive workflows, side effects, and agent-style automation -- [Virtual props](#virtual-props-outboxes-delivery-state-and-row-provenance) (`$synced`, `$origin`) for outbox views, sync indicators, and provenance-aware queries +- [Virtual props](#virtual-props-pending-writes-and-row-attribution) (`$hasPendingWrites`, `$origin`) for pending-write views and row attribution - [`queryOnce`](#queryonce) for one-shot queries using the same language as live queries - Indexes are now opt-in, and mutation handlers no longer rely on implicit return behavior (see [migration notes](#migration-notes)) @@ -89,6 +90,7 @@ In practice, the React Native setup looks like this: import { open } from '@op-engineering/op-sqlite' import { createCollection } from '@tanstack/db' import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { QueryClient } from '@tanstack/query-core' import { createReactNativeSQLitePersistence, persistedCollectionOptions, @@ -109,11 +111,14 @@ const persistence = createReactNativeSQLitePersistence({ database, }) +const queryClient = new QueryClient() + export const shoppingItemsCollection = createCollection( persistedCollectionOptions({ persistence, schemaVersion: 1, ...queryCollectionOptions({ + queryClient, queryKey: ['shopping-items'], queryFn: async () => api.shoppingItems.getAll(), getKey: (item) => item.id, @@ -339,46 +344,47 @@ await effect.dispose() Combined with [persistent local state](#persistent-local-state) in environments like Cloudflare Durable Objects, TanStack DB starts to resemble a durable state engine for agent workflows, not just a UI data layer. -This is one example, but it highlights how the 0.6 features work together: [includes](#includes-project-your-data-into-the-same-shape-as-your-ui), [virtual props](#virtual-props-outboxes-delivery-state-and-row-provenance), and reactive effects compose into something more capable than any single feature in isolation. +This is one example, but it highlights how the 0.6 features work together: [includes](#includes-project-your-data-into-the-same-shape-as-your-ui), [virtual props](#virtual-props-pending-writes-and-row-attribution), and reactive effects compose into something more capable than any single feature in isolation. -## Virtual props: outboxes, delivery state, and row provenance +## Virtual props: pending writes and row attribution Virtual props make row state visible directly in the query layer. They are: -- `$synced`: whether the row is confirmed by sync or still only local/optimistic -- `$origin`: whether the last confirmed change came from this client or from upstream sync +- `$hasPendingWrites`: whether a pending local optimistic mutation currently affects the row. It does not indicate backend acknowledgement. +- `$synced`: a deprecated inverse of `$hasPendingWrites`; use `$hasPendingWrites` for new code. +- `$origin`: `local` or `remote` collection attribution for the current row. Source writes are attributed by key and timing, not by a source client ID. - `$key`: the row key for the result - `$collectionId`: the source collection ID They can be used for workflow automation with `createEffect`, but are also immediately useful in the UI: -- outbox views of unpersisted data -- delivery or sync state indicators -- double-tick style UI patterns (e.g. WhatsApp) +- views of rows with pending local optimistic writes +- pending-write indicators +- workflows that react to optimistic state changes -Virtual props are one of those deceptively small features that end up being useful everywhere. +These props do not, by themselves, prove upload, server acknowledgement, or delivery to another user. For those states, keep the mutation pending until the relevant backend observation or expose adapter-specific status. See the [virtual properties guide](/db/latest/docs/guides/live-queries#virtual-properties) for current semantics. ```typescript import { createLiveQueryCollection, eq } from '@tanstack/db' -const outbox = createLiveQueryCollection({ - id: `outbox`, +const pendingMessages = createLiveQueryCollection({ + id: `pending-messages`, query: (q) => q .from({ item: messagesCollection }) - .where(({ item }) => eq(item.$synced, false)) + .where(({ item }) => eq(item.$hasPendingWrites, true)) .select(({ item }) => ({ id: item.id, body: item.body, - synced: item.$synced, + hasPendingWrites: item.$hasPendingWrites, origin: item.$origin, })), }) ``` -That query can drive a full outbox view, or smaller UI elements like delivery indicators in a chat interface. Because these props are queryable, they also pair naturally with [createEffect](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) when you want workflows driven by optimistic or confirmed state transitions. +That query can drive a view of locally pending messages or a pending-write indicator. Because these props are queryable, they also pair with [createEffect](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) when you want workflows driven by optimistic state transitions. ## queryOnce From 1dca899e959b496a64fd95a8e00e2e3ba32347e2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Oct 2026 13:36:46 -0600 Subject: [PATCH 2/5] docs: state guaranteed blog behavior directly --- src/blog/tanstack-db-0.5-query-driven-sync.md | 32 +++++++++---------- ...app-ready-with-persistence-and-includes.md | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/blog/tanstack-db-0.5-query-driven-sync.md b/src/blog/tanstack-db-0.5-query-driven-sync.md index a1beb5d14..554c61dcb 100644 --- a/src/blog/tanstack-db-0.5-query-driven-sync.md +++ b/src/blog/tanstack-db-0.5-query-driven-sync.md @@ -12,7 +12,7 @@ authors: ![Query-Driven Sync](/blog-assets/tanstack-db-0.5-query-driven-sync/header.png) -You don't need a new API for every component. With 0.5, the component's query can drive what your collection requests from the API. +You don't need a new API for every component. With an on-demand collection, the component's query determines the requested subset. ```tsx // Your component's query... @@ -26,7 +26,7 @@ const { data: projectTodos } = useLiveQuery((q) => .where(({ projects }) => eq(projects.id, 123)), ) -// With Query Collections that map these predicates, this can become: +// For Query Collections mapped to this API: // GET /api/projects/123 // GET /api/todos?projectId=123&status=active ``` @@ -144,7 +144,7 @@ const { data: electronics } = useLiveQuery((q) => ) ``` -For an API that supports these filters, your `queryFn` can turn the requested subset into: +For an API that supports these filters, your `queryFn` maps the requested subset to: ``` GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 @@ -160,11 +160,11 @@ If you're familiar with Relay or Apollo, this should feel familiar: components d "Wait," you're thinking, "doesn't this create N+1 query problems?" -Query Collection can share requests, while the number of network calls still depends on your collection adapter and API. +Query Collection shares identical concurrent requests. Join batching and other transport behavior depend on the collection adapter and API. ### **Automatic request collapsing** -Components requesting the same subset through one Query Collection can share a single in-flight fetch: +Components requesting the same subset concurrently through one Query Collection share a single in-flight fetch: ```tsx // Component A @@ -177,15 +177,15 @@ const { data: active } = useLiveQuery((q) => q.from({ todos }).where(({ todos }) => eq(todos.status, 'active')), ) -// With one Query Collection, both components can share this in-flight request: +// Both components share this in-flight request: // GET /api/todos?status=active ``` -Query Collection uses the requested subset in its TanStack Query cache key, so identical requests can share work. Custom collection adapters control their own transport deduplication; they can use `DeduplicatedLoadSubset` when they need it. +Query Collection uses the requested subset in its TanStack Query cache key, so identical concurrent requests share a Query fetch. Custom collection adapters must implement transport deduplication if they need it; `DeduplicatedLoadSubset` is available for that purpose. ### **Subset matching and delta loading** -For an on-demand Query Collection backed by an offset-based API, an ordered infinite query can load more rows: +For an on-demand Query Collection backed by an offset-based API, use an ordered infinite query to load more rows: ```tsx const { data, fetchNextPage } = useLiveInfiniteQuery( @@ -200,11 +200,11 @@ const { data, fetchNextPage } = useLiveInfiniteQuery( fetchNextPage() ``` -The hook widens the ordered query window and may request an extra row to determine whether another page exists. The `queryFn` must fulfill the requested filter, order, limit, and offset; a request is not necessarily one UI page. A different filter, order, or limit has a distinct request identity and may fetch rows that are already present locally. See the [server pagination guide](/db/latest/docs/collections/query-collection#server-pagination-with-live-queries) for the full contract. +The hook widens the ordered query window and requests an extra row to determine whether another page exists. The `queryFn` must fulfill the requested filter, order, limit, and offset; a request is not necessarily one UI page. A different filter, order, or limit has a distinct request identity. These requests can overlap rows already present locally. See the [server pagination guide](/db/latest/docs/collections/query-collection#server-pagination-with-live-queries) for the full contract. ### **Join optimization** -When an adapter maps pushed-down predicates to batched API filters, a join can request related rows together: +When an adapter maps pushed-down predicates to batched API filters, the join requests related rows together: ```tsx // Join todos with their projects @@ -268,7 +268,7 @@ useLiveQuery((q) => You get TanStack Query's caching for each requested subset. -**The result:** Shared requests and subset caching can reduce network work without adding a view-specific endpoint for every component. +**The result:** Identical requests share work, and one filterable API serves multiple component queries. ## Progressive sync: Fast initial paint + instant client queries @@ -291,17 +291,17 @@ const { data: urgentTodos } = useLiveQuery((q) => .from({ todos: todoCollection }) .where(({ todos }) => eq(todos.priority, 'urgent')), ) -// The first query can load the urgent subset before full sync completes. +// The first query requests the urgent subset while full sync runs. // Meanwhile, collection syncs full dataset in background -// After sync completes: subsequent queries can run against local data. +// After sync completes: subsequent queries run against local data. ``` -The first query can use a targeted network request while the full dataset syncs in the background. Once that sync completes, subsequent queries can use the local data. Actual latency depends on the API, network, and query. +The first query requests the urgent subset while the full dataset syncs in the background. Once that sync completes, subsequent queries use local data. Actual latency depends on the API, network, and query. -**Progressive mode works with Query Collection and any collection adapter that supports subset loading plus full background sync.** The Electric example above is one way to use it. Sync engines like Electric, Trailbase, and PowerSync can stream changed rows after the initial load, which can make a full local dataset economical to maintain. +**Progressive mode works with Query Collection and any collection adapter that supports subset loading plus full background sync.** The Electric example above is one way to use it. Sync engines like Electric, Trailbase, and PowerSync stream changed rows after the initial load, avoiding repeated full downloads. -With a REST API, the choice depends on the cost of fetching and refreshing the full dataset. You can use the same progressive loading pattern when that cost fits your application. +The same progressive loading pattern works with a REST-backed Query Collection. Choose it when fetching and refreshing the full dataset fits your application's needs. ## Works today with REST. Gets better with sync engines. diff --git a/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md b/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md index 652787c17..db0bd9d5f 100644 --- a/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md +++ b/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md @@ -384,7 +384,7 @@ const pendingMessages = createLiveQueryCollection({ }) ``` -That query can drive a view of locally pending messages or a pending-write indicator. Because these props are queryable, they also pair with [createEffect](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) when you want workflows driven by optimistic state transitions. +That query drives a view of locally pending messages or a pending-write indicator. Because these props are queryable, they also pair with [createEffect](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) for workflows driven by optimistic state transitions. ## queryOnce From b516a62a3facbe96809640e4f49a6008fb46af74 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Oct 2026 13:41:59 -0600 Subject: [PATCH 3/5] docs: avoid repeated setup in DB blog example --- ...b-0.1-the-embedded-client-database-for-tanstack-query.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md b/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md index c98cacbbc..04db5821d 100644 --- a/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md +++ b/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md @@ -294,12 +294,6 @@ You can do this: ```typescript // Load normalized collections upfront (3 broader calls) -import { createCollection } from '@tanstack/react-db' -import { queryCollectionOptions } from '@tanstack/query-db-collection' -import { QueryClient } from '@tanstack/query-core' - -const queryClient = new QueryClient() - const todoCollection = createCollection( queryCollectionOptions({ queryClient, From 039a6bdc7e3f53ac0a8e63ecca2fc7f81497c8ff Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Oct 2026 13:45:17 -0600 Subject: [PATCH 4/5] docs: revert copy edits and keep example fixes --- ...dded-client-database-for-tanstack-query.md | 3 +- src/blog/tanstack-db-0.5-query-driven-sync.md | 128 +++++++----------- ...app-ready-with-persistence-and-includes.md | 34 +++-- 3 files changed, 69 insertions(+), 96 deletions(-) diff --git a/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md b/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md index 04db5821d..6558e51a6 100644 --- a/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md +++ b/src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md @@ -1,7 +1,6 @@ --- title: Stop Re-Rendering. TanStack DB, the Embedded Client Database for TanStack Query published: 2025-07-30 -updated: 2026-10-13 excerpt: Your React dashboard shouldn't grind to a halt because one TODO gets checked. TanStack DB is a client-side database powered by differential dataflow that plugs into your existing useQuery calls. library: db authors: @@ -363,7 +362,7 @@ While TanStack DB works great with REST and GraphQL, it really shines when paire **Load far more data efficiently**: It's far cheaper to update data in the client when using sync engines. Instead of re-loading entire collections after each change, sync engines send only the actual changed items. This makes it practical to load far more data upfront, enabling the "load everything once" pattern that makes apps like Linear feel so fast. -TanStack DB was designed from the ground up to support sync engines. [When you define a collection, you're provided with an API for writing synced transactions](https://tanstack.com/db/latest/docs/guides/collection-options-creator) from the backend into your local collections. Try the current collection implementations for [Electric](https://tanstack.com/db/latest/docs/installation#electric-collection), [TrailBase](https://tanstack.com/db/latest/docs/installation#trailbase-collection), and [PowerSync](https://tanstack.com/db/latest/docs/installation#powersync-collection). +TanStack DB was designed from the ground up to support sync engines. [When you define a collection, you're provided with an API for writing synced transactions](https://tanstack.com/db/latest/docs/guides/collection-options-creator) from the backend into your local collections. Try out collection implementations for [Electric](https://tanstack.com/db/latest/docs/installation#electric-collection), [Trailblaze](https://tanstack.com/db/latest/docs/installation#trailbase-collection), and [(soon) Firebase](https://github.com/TanStack/db/pull/323)! DB gives you a common interface for your components to query data, which means you can easily switch between data loading strategies as needed without changing client code. Start with REST, switch to a sync engine later as needed, your components don't need to know the difference. diff --git a/src/blog/tanstack-db-0.5-query-driven-sync.md b/src/blog/tanstack-db-0.5-query-driven-sync.md index 554c61dcb..3211e8119 100644 --- a/src/blog/tanstack-db-0.5-query-driven-sync.md +++ b/src/blog/tanstack-db-0.5-query-driven-sync.md @@ -1,8 +1,7 @@ --- -title: TanStack DB 0.5: Query-Driven Sync +title: TanStack DB 0.5 . Query-Driven Sync published: 2025-11-12 -updated: 2026-10-13 -excerpt: Your component's query drives loading through your existing API. TanStack DB 0.5 introduces query-driven sync that reduces the glue code between your UI and your data. +excerpt: Your component's query is now the API call. No custom endpoint, no GraphQL resolver, no backend change. TanStack DB 0.5 introduces query-driven sync that eliminates the glue code between your UI and your data. library: db authors: - Sam Willis @@ -12,7 +11,7 @@ authors: ![Query-Driven Sync](/blog-assets/tanstack-db-0.5-query-driven-sync/header.png) -You don't need a new API for every component. With an on-demand collection, the component's query determines the requested subset. +You don't need a new API for every component. With 0.5, the component's query _is_ the API call. ```tsx // Your component's query... @@ -26,12 +25,12 @@ const { data: projectTodos } = useLiveQuery((q) => .where(({ projects }) => eq(projects.id, 123)), ) -// For Query Collections mapped to this API: +// ...becomes these precise API calls automatically: // GET /api/projects/123 // GET /api/todos?projectId=123&status=active ``` -If your existing API accepts these filters, you don't need a new endpoint or GraphQL resolver. Configure each collection's `queryFn` to map the requested predicates to that API. +No custom endpoint. No GraphQL resolver. No backend change. Just write your query and TanStack DB figures out exactly what to fetch. We're releasing TanStack DB 0.5 today with Query-Driven Sync. A feature that fundamentally changes how you think about loading data. @@ -144,7 +143,7 @@ const { data: electronics } = useLiveQuery((q) => ) ``` -For an API that supports these filters, your `queryFn` maps the requested subset to: +TanStack DB automatically calls your `queryFn` with: ``` GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 @@ -152,7 +151,7 @@ GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 **No custom API endpoint.** **No GraphQL schema changes.** Just a general-purpose products API that accepts filter parameters. -Your component's query guides the API request. +Your component's query becomes the API call. If you're familiar with Relay or Apollo, this should feel familiar: components declare their data needs, and the framework optimizes fetching and updates. The difference? You get Relay-style normalized caching and automatic updates without GraphQL. Your REST, GraphQL, or tRPC API stays simple, your queries stay powerful, and differential dataflow keeps everything fast client-side. @@ -160,11 +159,11 @@ If you're familiar with Relay or Apollo, this should feel familiar: components d "Wait," you're thinking, "doesn't this create N+1 query problems?" -Query Collection shares identical concurrent requests. Join batching and other transport behavior depend on the collection adapter and API. +No. And here's why the performance story is actually _better_ than custom APIs. ### **Automatic request collapsing** -Components requesting the same subset concurrently through one Query Collection share a single in-flight fetch: +Multiple components requesting the same data trigger exactly one network call: ```tsx // Component A @@ -177,34 +176,38 @@ const { data: active } = useLiveQuery((q) => q.from({ todos }).where(({ todos }) => eq(todos.status, 'active')), ) -// Both components share this in-flight request: +// Result: ONE network request // GET /api/todos?status=active ``` -Query Collection uses the requested subset in its TanStack Query cache key, so identical concurrent requests share a Query fetch. Custom collection adapters must implement transport deduplication if they need it; `DeduplicatedLoadSubset` is available for that purpose. +TanStack DB compares predicates across all live queries and deduplicates requests automatically. ### **Subset matching and delta loading** -For an on-demand Query Collection backed by an offset-based API, use an ordered infinite query to load more rows: +When you navigate from viewing 10 products to viewing 20, DB doesn't reload everything: ```tsx -const { data, fetchNextPage } = useLiveInfiniteQuery( - (q) => - q - .from({ product: productsCollection }) - .orderBy(({ product }) => product.id, 'asc'), - { pageSize: 10 }, -) +// Initial query: loads 10 products +const { data } = useLiveQuery((q) => q.from({ products }).limit(10)) -// In the "load more" button handler: +// User clicks "load more": loads ONLY the next 10 fetchNextPage() ``` -The hook widens the ordered query window and requests an extra row to determine whether another page exists. The `queryFn` must fulfill the requested filter, order, limit, and offset; a request is not necessarily one UI page. A different filter, order, or limit has a distinct request identity. These requests can overlap rows already present locally. See the [server pagination guide](/db/latest/docs/collections/query-collection#server-pagination-with-live-queries) for the full contract. +``` +# Page 1 +GET /api/products?limit=10&offset=0 + +# Page 2 +GET /api/products?limit=10&offset=10 +# NOT: GET /api/products?limit=20 +``` + +Already-loaded rows are reused; only the new window crosses the wire. The collection tracks which predicates it has already satisfied and only fetches the delta. ### **Join optimization** -When an adapter maps pushed-down predicates to batched API filters, the join requests related rows together: +Complex joins don't cause request explosions. They trigger a minimal set of filtered requests: ```tsx // Join todos with their projects @@ -224,51 +227,35 @@ const { data } = useLiveQuery((q) => // NOT 10 separate project requests! ``` -DB derives the related keys. The collection's `queryFn` determines how those keys become transport requests, including whether the API can fetch them in one batch. +DB analyzes the join to determine exactly which related records are needed, then fetches them in a single batched request. ### **Respects your cache policies** Query Collection integrates with TanStack Query's `staleTime` and `gcTime`: ```tsx -const productsCollection = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ['products'], - queryFn: fetchProducts, - getKey: (product) => product.id, - staleTime: 5 * 60 * 1000, // 5 minutes - syncMode: 'on-demand', - }), -) +const productsCollection = createCollection(queryCollectionOptions({ + queryClient, + queryKey: ['products'], + queryFn: fetchProducts, + getKey: (product) => product.id, + staleTime: 5 * 60 * 1000, // 5 minutes + syncMode: 'on-demand' +})) // First query: network request -useLiveQuery((q) => - q - .from({ product: productsCollection }) - .where(({ product }) => eq(product.category, 'hardware')), -) +useLiveQuery(q => q.from({ products }).where(...)) -// Identical request within 5 minutes: reuse the cached result -useLiveQuery((q) => - q - .from({ product: productsCollection }) - .where(({ product }) => eq(product.category, 'hardware')), -) +// Same query within 5 minutes: instant, no network +useLiveQuery(q => q.from({ products }).where(...)) -// Different order/limit: a distinct request that may fetch overlapping rows -useLiveQuery((q) => - q - .from({ product: productsCollection }) - .where(({ product }) => eq(product.category, 'hardware')) - .orderBy(({ product }) => product.id, 'asc') - .limit(20), -) +// Different query within 5 minutes: only fetches the diff +useLiveQuery(q => q.from({ products }).where(...).limit(20)) ``` -You get TanStack Query's caching for each requested subset. +You get TanStack Query's sophisticated caching plus DB's intelligent subset tracking. -**The result:** Identical requests share work, and one filterable API serves multiple component queries. +**The result:** Fewer total network requests than custom view-specific APIs, with better cache utilization and zero endpoint sprawl. ## Progressive sync: Fast initial paint + instant client queries @@ -291,17 +278,17 @@ const { data: urgentTodos } = useLiveQuery((q) => .from({ todos: todoCollection }) .where(({ todos }) => eq(todos.priority, 'urgent')), ) -// The first query requests the urgent subset while full sync runs. +// ~100ms: Network request for urgent todos only // Meanwhile, collection syncs full dataset in background -// After sync completes: subsequent queries run against local data. +// After sync completes: all queries run in <1ms client-side ``` -The first query requests the urgent subset while the full dataset syncs in the background. Once that sync completes, subsequent queries use local data. Actual latency depends on the API, network, and query. +Now your first query loads in ~100ms with a targeted network request. While the user interacts with that data, the full dataset syncs in the background. Once complete, all subsequent queries (even complex joins and filters) run in sub-millisecond time purely client-side. -**Progressive mode works with Query Collection and any collection adapter that supports subset loading plus full background sync.** The Electric example above is one way to use it. Sync engines like Electric, Trailbase, and PowerSync stream changed rows after the initial load, avoiding repeated full downloads. +**Progressive mode shines with sync engines** like Electric, Trailbase, and PowerSync. With traditional fetch approaches, loading more data means re-fetching everything, which gets expensive fast. But sync engines only send deltas (the actual changed rows), making it cheap to maintain large client-side datasets. You get instant queries over 10,000s of rows without the network cost of repeatedly fetching all that data. -The same progressive loading pattern works with a REST-backed Query Collection. Choose it when fetching and refreshing the full dataset fits your application's needs. +With REST APIs, progressive mode is less common since updates generally require full re-fetches. But for sync engines, it's often the sweet spot: fast first paint + instant everything else. ## Works today with REST. Gets better with sync engines. @@ -310,10 +297,10 @@ Query-Driven Sync is designed to work with your existing REST, GraphQL, or tRPC For teams using sync engines like [Electric](https://electric-sql.com), [Trailbase](https://trailbase.io/), or [PowerSync](https://www.powersync.com/), you get additional benefits: - **Real-time updates** via streaming (no polling required) -- **Adapter-specific predicate translation** for supported queries +- **Automatic predicate translation** (no manual mapping needed) - **Delta-only syncing** (only changed rows cross the wire) -For example, the Electric collection adapter translates supported subset predicates into shape requests and streams updates. Configure your Electric proxy to authenticate and authorize those requests before serving data. +For example, Electric translates your client query directly into Postgres queries, applies authorization rules, and streams updates. Your component's query becomes a secure, real-time, authorized Postgres query, no API endpoint needed. Collections abstract the data source. Start with REST. Upgrade to sync when you need real-time. @@ -326,7 +313,7 @@ We provide helper functions to make this straightforward: ```tsx queryFn: async (ctx) => { // Parse expression trees into a simple format - const { filters, sorts, limit, offset } = parseLoadSubsetOptions( + const { filters, sorts, limit } = parseLoadSubsetOptions( ctx.meta?.loadSubsetOptions, ) @@ -338,16 +325,7 @@ queryFn: async (ctx) => { params.set(`${field.join('.')}_lt`, String(value)) // Map other operators as needed }) - if (sorts.length > 0) { - params.set( - 'sort', - sorts - .map(({ field, direction }) => `${field.join('.')}:${direction}`) - .join(','), - ) - } - if (limit !== undefined) params.set('limit', String(limit)) - if (offset !== undefined) params.set('offset', String(offset)) + if (limit) params.set('limit', String(limit)) return fetch(`/api/products?${params}`).then((r) => r.json()) } @@ -357,7 +335,7 @@ For APIs with custom formats (like GraphQL), use `parseWhereExpression` with cus ```tsx queryFn: async (ctx) => { - const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions // Map to GraphQL's where clause format const whereClause = parseWhereExpression(where, { @@ -382,8 +360,6 @@ You write this mapping once per collection. After that, every query automaticall Query-Driven Sync (0.5) completes the core vision: intelligent loading that adapts to your queries, instant client-side updates via differential dataflow, and seamless persistence back to your backend. We're targeting 1.0 for December 2025, focusing on API stability and comprehensive docs. -> **Timeline update (October 2026):** December 2025 was our target when this post was published. TanStack DB is still in beta; see the [current DB documentation](/db/latest/docs/overview) for its present status. - **This is new. We need early adopters.** Query-Driven Sync works and ships today, but it's fresh. If you try it, we'd love your feedback on rough edges or API improvements. Join us in [Discord](https://discord.gg/tanstack) or open [GitHub issues](https://github.com/TanStack/db/issues). If you have ideas for new collection types based on Query-Driven Sync, please reach out. The interface is very powerful and we have lots of interesting ideas for how it can be used. diff --git a/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md b/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md index db0bd9d5f..26b63d609 100644 --- a/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md +++ b/src/blog/tanstack-db-0.6-app-ready-with-persistence-and-includes.md @@ -1,8 +1,7 @@ --- title: TanStack DB 0.6 Now Includes Persistence, Offline Support, and Hierarchical Data -excerpt: TanStack DB 0.6 adds SQLite-backed persistence across runtimes, hierarchical includes for projecting normalized data into UI-shaped trees, reactive effects, virtual props for pending local writes, and more. +excerpt: TanStack DB 0.6 adds SQLite-backed persistence across runtimes, hierarchical includes for projecting normalized data into UI-shaped trees, reactive effects, virtual props for sync state, and more. published: 2026-03-25 -updated: 2026-10-13 draft: false library: db authors: @@ -14,14 +13,14 @@ authors: With v0.6 of TanStack DB, we're bringing some highly anticipated features and improving the ergonomics for app development. -You can now project normalized data into the same hierarchical shape as your UI, persist local state across runtimes with a SQLite-backed layer, and trigger reactive side effects directly from live queries. Row metadata makes pending local writes and collection attribution queryable, and APIs that previously relied on implicit behavior are now explicit and consistent. +You can now project normalized data into the same hierarchical shape as your UI, persist local state across runtimes with a SQLite-backed layer, and trigger reactive side effects directly from live queries. Row metadata unlocks outbox views and delivery indicators (think WhatsApp-style), and APIs that previously relied on implicit behavior are now explicit and consistent. Here’s what shipped: - [Persistent local state](#persistent-local-state) via SQLite-backed adapters across browser, React Native, Expo, Node, Electron, Capacitor, Tauri, and Cloudflare Durable Objects - [Includes](#includes-project-your-data-into-the-same-shape-as-your-ui) for projecting normalized data into the hierarchical structure of your UI. Similar to GraphQL, but without the need for new infrastructure. - [`createEffect`](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) for reactive workflows, side effects, and agent-style automation -- [Virtual props](#virtual-props-pending-writes-and-row-attribution) (`$hasPendingWrites`, `$origin`) for pending-write views and row attribution +- [Virtual props](#virtual-props-outboxes-delivery-state-and-row-provenance) (`$synced`, `$origin`) for outbox views, sync indicators, and provenance-aware queries - [`queryOnce`](#queryonce) for one-shot queries using the same language as live queries - Indexes are now opt-in, and mutation handlers no longer rely on implicit return behavior (see [migration notes](#migration-notes)) @@ -344,47 +343,46 @@ await effect.dispose() Combined with [persistent local state](#persistent-local-state) in environments like Cloudflare Durable Objects, TanStack DB starts to resemble a durable state engine for agent workflows, not just a UI data layer. -This is one example, but it highlights how the 0.6 features work together: [includes](#includes-project-your-data-into-the-same-shape-as-your-ui), [virtual props](#virtual-props-pending-writes-and-row-attribution), and reactive effects compose into something more capable than any single feature in isolation. +This is one example, but it highlights how the 0.6 features work together: [includes](#includes-project-your-data-into-the-same-shape-as-your-ui), [virtual props](#virtual-props-outboxes-delivery-state-and-row-provenance), and reactive effects compose into something more capable than any single feature in isolation. -## Virtual props: pending writes and row attribution +## Virtual props: outboxes, delivery state, and row provenance Virtual props make row state visible directly in the query layer. They are: -- `$hasPendingWrites`: whether a pending local optimistic mutation currently affects the row. It does not indicate backend acknowledgement. -- `$synced`: a deprecated inverse of `$hasPendingWrites`; use `$hasPendingWrites` for new code. -- `$origin`: `local` or `remote` collection attribution for the current row. Source writes are attributed by key and timing, not by a source client ID. +- `$synced`: whether the row is confirmed by sync or still only local/optimistic +- `$origin`: whether the last confirmed change came from this client or from upstream sync - `$key`: the row key for the result - `$collectionId`: the source collection ID They can be used for workflow automation with `createEffect`, but are also immediately useful in the UI: -- views of rows with pending local optimistic writes -- pending-write indicators -- workflows that react to optimistic state changes +- outbox views of unpersisted data +- delivery or sync state indicators +- double-tick style UI patterns (e.g. WhatsApp) -These props do not, by themselves, prove upload, server acknowledgement, or delivery to another user. For those states, keep the mutation pending until the relevant backend observation or expose adapter-specific status. See the [virtual properties guide](/db/latest/docs/guides/live-queries#virtual-properties) for current semantics. +Virtual props are one of those deceptively small features that end up being useful everywhere. ```typescript import { createLiveQueryCollection, eq } from '@tanstack/db' -const pendingMessages = createLiveQueryCollection({ - id: `pending-messages`, +const outbox = createLiveQueryCollection({ + id: `outbox`, query: (q) => q .from({ item: messagesCollection }) - .where(({ item }) => eq(item.$hasPendingWrites, true)) + .where(({ item }) => eq(item.$synced, false)) .select(({ item }) => ({ id: item.id, body: item.body, - hasPendingWrites: item.$hasPendingWrites, + synced: item.$synced, origin: item.$origin, })), }) ``` -That query drives a view of locally pending messages or a pending-write indicator. Because these props are queryable, they also pair with [createEffect](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) for workflows driven by optimistic state transitions. +That query can drive a full outbox view, or smaller UI elements like delivery indicators in a chat interface. Because these props are queryable, they also pair naturally with [createEffect](#createeffect-reactive-side-effects-for-workflows-tools-and-agents) when you want workflows driven by optimistic or confirmed state transitions. ## queryOnce From cc7b841574fa8382fc741d3d60a5173c809d15e9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Oct 2026 13:52:20 -0600 Subject: [PATCH 5/5] docs(db): fix load-more code example --- src/blog/tanstack-db-0.5-query-driven-sync.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/blog/tanstack-db-0.5-query-driven-sync.md b/src/blog/tanstack-db-0.5-query-driven-sync.md index 3211e8119..63352759a 100644 --- a/src/blog/tanstack-db-0.5-query-driven-sync.md +++ b/src/blog/tanstack-db-0.5-query-driven-sync.md @@ -187,11 +187,19 @@ TanStack DB compares predicates across all live queries and deduplicates request When you navigate from viewing 10 products to viewing 20, DB doesn't reload everything: ```tsx +import { createLiveQueryCollection } from '@tanstack/react-db' + // Initial query: loads 10 products -const { data } = useLiveQuery((q) => q.from({ products }).limit(10)) +const productsQuery = createLiveQueryCollection((q) => + q + .from({ products: productsCollection }) + .orderBy(({ products }) => products.id) + .limit(10), +) +const { data } = useLiveQuery(productsQuery) // User clicks "load more": loads ONLY the next 10 -fetchNextPage() +await productsQuery.utils.setWindow({ limit: 20 }) ``` ```