A hands-on tutorial for TanStack DB 1.0: build a React expense tracker with collections, differential dataflow live queries, joins, aggregates, and optimistic mutations, with the sharp corners called out along the way.
TanStack DB 1.0 is out after more than a year in beta, and it is a genuinely different take on client state: you load data into typed collections, query them with SQL-style live queries powered by differential dataflow, and mutate them through optimistic transactions that roll back on failure. Queries with joins and aggregates update incrementally instead of re-running, which is why the docs can claim complex queries on every keystroke without lying. It extends TanStack Query rather than replacing it, and it is sync-engine agnostic: the same collection API sits in front of ElectricSQL, tRPC, or a plain REST endpoint.
The best way to find the sharp edges is to build against it. This tutorial builds Spendlog, a small expense tracker that exercises collections, useLiveQuery, where/join/groupBy, and optimistic inserts and deletes, then swaps the storage layer for a real API without touching a line of UI code. Scaffold with npm create vite@latest spendlog -- --template react-ts, then npm install @tanstack/react-db. Two more packages arrive near the end. Each step flags the gotcha that will bite you in that exact spot.
import {
createCollection,
localOnlyCollectionOptions,
} from "@tanstack/react-db"
export type Expense = {
id: string
title: string
amount: number
categoryId: string
createdAt: number
}
export const expenseCollection = createCollection(
localOnlyCollectionOptions({
getKey: (e: Expense) => e.id,
})
)Everything in TanStack DB lives in a collection: a typed set of objects with a primary key. Create one with createCollection and, for now, localOnlyCollectionOptions so the demo runs with zero backend. Both come from @tanstack/react-db.
Two details matter here. First, the row type is inferred from the getKey parameter annotation, so type it explicitly. Second, getKey must return a value that is unique and stable for the row's lifetime. If two rows ever share a key, the store overwrites silently rather than erroring, which is a miserable bug to trace back.
Add a second collection for categories, seeded through initialData. Having two collections is deliberate: it lets us demonstrate joins later, and it shows that collections are cheap. You are meant to have many small ones, not one giant store.
Gotcha: local-only collections are in-memory. Reload the page and the data is gone. That is fine for categories we re-seed on boot, but if you want client-only state that survives refreshes, localStorageCollectionOptions exists and syncs across tabs. Do not reach for it as a database, though. It serializes the whole collection on write.
useLiveQuery takes a builder callback. q.from({ e: expenseCollection }) registers the collection under the alias e, and the alias object is how every later clause refers to it.
The key mental shift: this is a subscription, not a fetch. When a row changes, the differential dataflow engine pushes exactly that delta through the compiled pipeline instead of re-running the query. That is what makes it safe to have dozens of live queries mounted at once.
Note that no provider or context wrapper appears anywhere. Collections are module-level singletons and components subscribe directly. data is a plain array, empty until rows exist.
Add form state and an entry form. The category <select> is populated by a second useLiveQuery against categoryCollection, not a hardcoded list. This is the pattern TanStack DB pushes you toward: any component reads any collection directly, and the engine dedupes the work.
One caveat for later: local-only collections are ready synchronously, so data is safe to map immediately. Once a collection is backed by a network fetch (as ours will be in step 10), useLiveQuery also returns isLoading and status. Mapping over data still works because it starts as an empty array, but a real app should render the loading state.
Wire the form to expenseCollection.insert(). The call is synchronous from the UI's perspective: the row lands in optimistic state immediately and every live query touching it updates in the same frame. No mutation hooks, no cache invalidation.
The sharpest corner in the whole library lives here: the key returned by getKey must exist on the object at insert time. That means client-generated IDs via crypto.randomUUID(). If your backend hands out autoincrement integers, optimistic inserts get awkward. You either generate a temporary ID and reconcile after the server responds, or move to UUIDs server-side. Plan for this before adopting, not after.
Add a category filter using .where() and the eq helper. Two traps in one step.
First, the big one: the query closure captures filter, and TanStack DB compiles the builder once. If component state used inside the query changes, the query does not know. You must pass a deps array as the second argument to useLiveQuery, exactly like useEffect. Forget it and the filter silently never updates. This will be the first bug every adopter ships.
Second: expressions inside where must use builder functions like eq, gt, and, or. A plain e.categoryId === filter will not compile into the dataflow pipeline. That is also why we branch outside the chain for the "all" case.
Replace the raw category ID with the category name by joining the two collections. .join() takes an alias object, an eq condition, and an optional join type.
Gotcha: joins default to left join. Without the third "inner" argument, an expense pointing at a deleted category still comes through with c as undefined, and c.name throws at runtime while TypeScript stays quiet about it. Pass "inner" unless you genuinely want unmatched rows.
.orderBy() with "desc" sorts newest first, and .select() projects the joined row into a flat shape. After a select, the row type is whatever you returned, so the render code switches to e.category.
Add a third live query that groups expenses by category and sums the amounts, using groupBy and the sum aggregate (also available: count, avg, min, max).
The SQL rule applies: every non-aggregate column in select must appear in groupBy, or the query throws when it compiles. Here c.name is the grouping key, so selecting it is legal.
This is where differential dataflow pays off visibly. Add an expense and watch the total tick up in the same frame as the list update. The engine adjusts the affected group's sum incrementally; it never rescans the collection. Aggregations over live data are usually where client stores fall over, and this one does not.
Deleting is one call: expenseCollection.delete(e.id) takes the key, removes the row optimistically, and every query (including the totals) adjusts instantly.
Updates are worth knowing even though this app does not need one: expenseCollection.update(id, (draft) => { draft.amount += 5 }) uses an Immer-style draft. You mutate the draft in place. Returning a new object from the callback is the classic mistake; the return value is ignored and your change evaporates. Both update and delete also accept arrays of keys for batch operations, which land as a single transaction.
Now the payoff of the collection abstraction. Install @tanstack/query-db-collection and @tanstack/query-core, then replace the local-only expense collection with queryCollectionOptions. It loads data through TanStack Query (queryKey, queryFn) and persists writes through onInsert, onUpdate, and onDelete handlers. Each handler receives the optimistic transaction and its mutations array. App.tsx does not change at all.
Two gotchas. Define a handler for every mutation type you use; calling delete on a collection with no onDelete throws. And by default the collection refetches the query after each handler resolves to reconcile with the server. If your handler already returns authoritative data, return { refetch: false } to skip the extra round trip.
Final gotcha, and it is the one that matters in production. If a persistence handler throws (network down, server 500), TanStack DB rolls the optimistic change back automatically. Correct behavior, but silent: the row the user just added simply vanishes with no error surfaced anywhere.
Every mutation call returns a transaction object. Its isPersisted.promise resolves when the handler succeeds and rejects on rollback, so catch it and tell the user. In a bigger app you would centralize this in a toast helper rather than per-call state, but the principle stands: an unwatched isPersisted.promise is data loss the user watches happen. With that handled, Spendlog is complete.
The finished app demonstrates the 1.0 pitch end to end: components subscribe to queries, not fetches, and the storage layer is swappable behind createCollection. It also surfaced every corner worth knowing before you adopt. The useLiveQuery deps array is the bug you will ship first. Expressions must use query builder functions like eq, not plain JavaScript. Joins default to left, so unmatched rows carry undefined until you pass "inner". Client-generated keys are effectively mandatory for optimistic inserts, which is awkward against autoincrement APIs. And silent rollback means isPersisted.promise handling is not optional in production code.
Where it earns its place: read-heavy UIs where many components derive different views of the same data, and apps heading toward a sync engine later, since collections make that a config change. Where to hold off: TypeScript inference on long builder chains can get gnarly, and the collection ecosystem beyond Query, Electric, and local storage is young. For a 1.0, though, the core engine is impressively solid, and the incremental query model is the part that will stick.