tanstack-db
SkillDev toolsLets your agent write React components that query and update app data live using TanStack DB hooks in Octane projects.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the tanstack-db skill
About this capability
Octane bindings for TanStack DB. useLiveQuery hook with dependency arrays (8 overloads: query function, config object, pre-created collection, disabled state via returning undefined/null). useLiveSuspenseQuery for Octane Suspense with Error Boundaries (data always defined). useLiveInfiniteQuery for
What this skill tells your AI
The instructions your AI receives, as published by octanejs/octane in packages/tanstack-db/skills/tanstack-db/SKILL.md and read by ahel’s review.
This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns.
TanStack DB — Octane
Setup
import { useLiveQuery, eq, not } from '@octanejs/tanstack-db'
function TodoList() {
const { data: todos, isLoading } = useLiveQuery((q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => not(todo.completed))
.orderBy(({ todo }) => todo.created_at, 'asc'),
)
if (isLoading) return <div>Loading...</div>
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
)
}
@octanejs/tanstack-db re-exports everything from @tanstack/db. In Octane projects, import everything from @octanejs/tanstack-db.
Hooks
useLiveQuery
// Query function with dependency array
const {
data,
state,
collection,
status,
isLoading,
isReady,
isError,
isIdle,
isCleanedUp,
} = useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId)),
[userId],
)
// Config object
const { data } = useLiveQuery({
query: (q) => q.from({ todo: todoCollection }),
gcTime: 60000,
})
// Pre-created collection (from route loader)
const { data } = useLiveQuery(preloadedCollection)
// Conditional query — return undefined/null to disable
const { data, status } = useLiveQuery(
(q) => {
if (!userId) return undefined
return q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId))
},
[userId],
)
// When disabled: status='disabled', data=undefined
useLiveSuspenseQuery
// data is ALWAYS defined — never undefined
// Must wrap in <Suspense> and <ErrorBoundary>
function TodoList() {
const { data: todos } = useLiveSuspenseQuery((q) =>
q.from({ todo: todoCollection }),
)
return (
<ul>
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
)
}
// With deps — re-suspends when deps change
const { data } = useLiveSuspenseQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.category, category)),
[category],
)
useLiveInfiniteQuery
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useLiveInfiniteQuery(
(q) =>
q
.from({ posts: postsCollection })
.where(({ posts }) => eq(posts.category, category))
.orderBy(({ posts }) => posts.createdAt, 'desc'),
{ pageSize: 20 },
[category],
)
// data is the flat array of all loaded pages
// fetchNextPage() loads the next page
// hasNextPage is true when more data is available
usePacedMutations
import { usePacedMutations, debounceStrategy } from "@octanejs/tanstack-db"
const mutate = usePacedMutations({
onMutate: (value: string) => {
noteCollection.update(noteId, (draft) => {
draft.content = value
})
},
mutationFn: async ({ transaction }) => {
await api.notes.update(noteId, transaction.mutations[0].changes)
},
strategy: debounceStrategy({ wait: 500 }),
})
// In handler:
<textarea onChange={(e) => mutate(e.target.value)} />
useLiveQueryEffect
import { useLiveQueryEffect, eq } from '@octanejs/tanstack-db'
// Fire side effects when rows enter, exit, or update a query result.
// No render output — the effect is created on mount, disposed on unmount,
// and recreated when deps change.
function ChatComponent() {
useLiveQueryEffect(
{
query: (q) =>
q.from({ msg: messages }).where(({ msg }) => eq(msg.role, 'user')),
skipInitial: true,
onEnter: async (event) => {
await generateResponse(event.value)
},
},
[],
)
return <div>...</div>
}
Includes (Hierarchical Data)
When a query uses includes (subqueries in select), each child field is a live Collection by default. Subscribe to it with useLiveQuery in a subcomponent:
function ProjectList() {
const { data: projects } = useLiveQuery((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ id: i.id, title: i.title })),
})),
)
return (
<ul>
{projects.map((project) => (
<li key={project.id}>
{project.name}
<IssueList issuesCollection={project.issues} />
</li>
))}
</ul>
)
}
// Child component subscribes to the child Collection
function IssueList({ issuesCollection }) {
const { data: issues } = useLiveQuery(issuesCollection)
return (
<ul>
{issues.map((issue) => (
<li key={issue.id}>{issue.title}</li>
))}
</ul>
)
}
Only the affected IssueList re-renders when an issue changes — the parent does not.
With toArray(), child results are plain arrays and the parent re-renders on child changes:
import { toArray, eq } from '@octanejs/tanstack-db'
const { data: projects } = useLiveQuery((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: toArray(
q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ id: i.id, title: i.title })),
),
})),
)
// project.issues is Array<{ id, title }> — no subcomponent needed
See db-core/live-queries/SKILL.md for full includes rules (correlation conditions, nested includes, aggregates).
Virtual Properties
Live query results include computed, read-only virtual properties on every row:
$synced:truewhen the row is confirmed by sync;falsewhen it is still optimistic.$origin:"local"if the last confirmed change came from this client, otherwise"remote".$key: the row key for the result.$collectionId: the source collection ID.
These props are added automatically and can be used in where, select, and orderBy clauses. Do not persist them back to storage.
const { data } = useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.$synced, false)),
[],
)
// Shows only optimistic (unconfirmed) todos
Octane-Specific Patterns
Dependency arrays
// Include ALL external reactive values
const { data } = useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) =>
and(eq(todo.userId, userId), eq(todo.status, filter)),
),
[userId, filter],
)
// Empty array = static query, never re-runs
const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), [])
// No array = re-runs on every render (usually wrong)
Suspense + Error Boundary
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
Router loader preloading
// In route loader:
await todoCollection.preload()
// In component — data available immediately:
const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }))
See meta-framework/SKILL.md for full preloading patterns.
Common Mistakes
CRITICAL Missing external values in dependency array
Wrong:
const { data } = useLiveQuery((q) =>
q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.userId, userId)),
)
Correct:
const { data } = useLiveQuery(
(q) =>
q
.from({ todo: todoCollection })
.where(({ todo }) => eq(todo.userId, userId)),
[userId],
)
When the query uses external state not in the deps array, the query won't re-run when that value changes, showing stale results.
Source: docs/framework/octane/overview.md
HIGH useLiveSuspenseQuery without Error Boundary
Wrong:
<Suspense fallback={<div>Loading...</div>}>
<TodoList /> {/* uses useLiveSuspenseQuery */}
</Suspense>
Correct:
<ErrorBoundary fallback={<div>Error</div>}>
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
useLiveSuspenseQuery throws errors during rendering. Without an Error Boundary, the entire app crashes.
Source: docs/guides/live-queries.md
HIGH "Not a Collection" error from duplicate @tanstack/db
If useLiveQuery throws InvalidSourceError: The value provided for alias "todo" is not a Collection, it usually means two copies of @tanstack/db are installed. The collection was created by one copy, but useLiveQuery checks instanceof against the other.
In dev mode, TanStack DB also throws DuplicateDbInstanceError if two instances are detected.
Diagnose:
pnpm ls @tanstack/db
If multiple versions appear, fix with one of:
pnpm overrides (in root package.json):
{
"pnpm": {
"overrides": {
"@tanstack/db": "^0.6.0"
}
}
}
Vite resolve.alias (in vite.config.ts):
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'@tanstack/db': path.resolve('./node_modules/@tanstack/db'),
},
},
})
The root cause is typically a dependency that bundles its own copy instead of declaring @tanstack/db as a peerDependency.
HIGH Tension: Query expressiveness vs. IVM constraints
The query builder looks like SQL but has constraints that SQL doesn't — equality joins only, orderBy required for limit/offset, no distinct without select. Agents write SQL-style queries that violate these constraints. See db-core/live-queries/SKILL.md § Common Mistakes for all constraints.
See also: db-core/live-queries/SKILL.md — for query builder API and all operators.
See also: db-core/mutations-optimistic/SKILL.md — for mutation patterns.
See also: meta-framework/SKILL.md — for preloading in route loaders.
Signals
- GitHub stars
- 1k
- Forks
- 53
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
tanstack-db- Source
- github.com/octanejs/octane