from
Clausesjoin
Clauseslimit
and offset
eq(left, right)
gt(left, right)
, gte(left, right)
, lt(left, right)
, lte(left, right)
inArray(value, array)
like(value, pattern)
, ilike(value, pattern)
and(...conditions)
or(...conditions)
not(condition)
upper(value)
, lower(value)
length(value)
concat(...values)
add(left, right)
coalesce(...values)
count(value)
sum(value)
avg(value)
min(value)
, max(value)
TanStack DB provides a powerful, type-safe query system that allows you to fetch, filter, transform, and aggregate data from collections using a SQL-like fluent API. All queries are live by default, meaning they automatically update when the underlying data changes.
The query system is built around an API similar to SQL query builders like Kysely or Drizzle where you chain methods together to compose your query. The query builder doesn't perform operations in the order of method calls - instead, it composes your query into an optimal incremental pipeline that gets compiled and executed efficiently. Each method returns a new query builder, allowing you to chain operations together.
Live queries resolve to collections that automatically update when their underlying data changes. You can subscribe to changes, iterate over results, and use all the standard collection methods.
import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db'
const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
email: user.email,
}))
}))
import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db'
const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
email: user.email,
}))
}))
The result types are automatically inferred from your query structure, providing full TypeScript support. When you use a select clause, the result type matches your projection. Without select, you get the full schema with proper join optionality.
To create a live query collection, you can use liveQueryCollectionOptions with createCollection, or use the convenience function createLiveQueryCollection.
The fundamental way to create a live query is using liveQueryCollectionOptions with createCollection:
import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db'
const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
}))
import { createCollection, liveQueryCollectionOptions, eq } from '@tanstack/db'
const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
}))
For more control, you can specify additional options:
const activeUsers = createCollection(liveQueryCollectionOptions({
id: 'active-users', // Optional: auto-generated if not provided
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
})),
getKey: (user) => user.id, // Optional: uses stream key if not provided
startSync: true, // Optional: starts sync immediately
}))
const activeUsers = createCollection(liveQueryCollectionOptions({
id: 'active-users', // Optional: auto-generated if not provided
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
})),
getKey: (user) => user.id, // Optional: uses stream key if not provided
startSync: true, // Optional: starts sync immediately
}))
Option | Type | Description |
---|---|---|
id | string (optional) | An optional unique identifier for the live query. If not provided, it will be auto-generated. This is useful for debugging and logging. |
query | QueryBuilder or function | The query definition, this is either a Query instance or a function that returns a Query instance. |
getKey | (item) => string | number (optional) | A function that extracts a unique key from each row. If not provided, the stream's internal key will be used. For simple cases this is the key from the parent collection, but in the case of joins, the auto-generated key will be a composite of the parent keys. Using getKey is useful when you want to use a specific key from a parent collection for the resulting collection. |
schema | Schema (optional) | Optional schema for validation |
startSync | boolean (optional) | Whether to start syncing immediately. Defaults to true. |
gcTime | number (optional) | Garbage collection time in milliseconds. Defaults to 5000 (5 seconds). |
For simpler cases, you can use createLiveQueryCollection as a shortcut:
import { createLiveQueryCollection, eq } from '@tanstack/db'
const activeUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)
import { createLiveQueryCollection, eq } from '@tanstack/db'
const activeUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)
In React, you can use the useLiveQuery hook:
import { useLiveQuery } from '@tanstack/react-db'
function UserList() {
const activeUsers = useLiveQuery((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)
return (
<ul>
{activeUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
import { useLiveQuery } from '@tanstack/react-db'
function UserList() {
const activeUsers = useLiveQuery((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)
return (
<ul>
{activeUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
For more details on framework integration, see the React and Vue adapter documentation.
The foundation of every query is the from method, which specifies the source collection or subquery. You can alias the source using object syntax.
from({
[alias]: Collection | Query,
}): Query
from({
[alias]: Collection | Query,
}): Query
Parameters:
Start with a basic query that selects all records from a collection:
const allUsers = createCollection(liveQueryCollectionOptions({
query: (q) => q.from({ user: usersCollection })
}))
const allUsers = createCollection(liveQueryCollectionOptions({
query: (q) => q.from({ user: usersCollection })
}))
The result contains all users with their full schema. You can iterate over the results or access them by key:
// Get all users as an array
const users = allUsers.toArray
// Get a specific user by ID
const user = allUsers.get(1)
// Check if a user exists
const hasUser = allUsers.has(1)
// Get all users as an array
const users = allUsers.toArray
// Get a specific user by ID
const user = allUsers.get(1)
// Check if a user exists
const hasUser = allUsers.has(1)
Use aliases to make your queries more readable, especially when working with multiple collections:
const users = createCollection(liveQueryCollectionOptions({
query: (q) => q.from({ u: usersCollection })
}))
// Access fields using the alias
const userNames = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ u: usersCollection })
.select(({ u }) => ({
name: u.name,
email: u.email,
}))
}))
const users = createCollection(liveQueryCollectionOptions({
query: (q) => q.from({ u: usersCollection })
}))
// Access fields using the alias
const userNames = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ u: usersCollection })
.select(({ u }) => ({
name: u.name,
email: u.email,
}))
}))
Use where clauses to filter your data based on conditions. You can chain multiple where calls - they are combined with and logic.
The where method takes a callback function that receives an object containing your table aliases and returns a boolean expression. You build these expressions using comparison functions like eq(), gt(), and logical operators like and() and or(). This declarative approach allows the query system to optimize your filters efficiently. These are described in more detail in the Expression Functions Reference section. This is very similar to how you construct queries using Kysely or Drizzle.
It's important to note that the where method is not a function that is executed on each row or the results, its a way to describe the query that will be executed. This declarative approach works well for almost all use cases, but if you need to use a more complex condition, there is the functional variant as fn.where which is described in the Functional Variants section.
where(
condition: (row: TRow) => Expression<boolean>
): Query
where(
condition: (row: TRow) => Expression<boolean>
): Query
Parameters:
Filter users by a simple condition:
import { eq } from '@tanstack/db'
const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
}))
import { eq } from '@tanstack/db'
const activeUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
}))
Chain multiple where calls for AND logic:
import { eq, gt } from '@tanstack/db'
const adultActiveUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.where(({ user }) => gt(user.age, 18))
}))
import { eq, gt } from '@tanstack/db'
const adultActiveUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
.where(({ user }) => gt(user.age, 18))
}))
Use logical operators to build complex conditions:
import { eq, gt, or, and } from '@tanstack/db'
const specialUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) =>
and(
eq(user.active, true),
or(
gt(user.age, 25),
eq(user.role, 'admin')
)
)
)
)
import { eq, gt, or, and } from '@tanstack/db'
const specialUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) =>
and(
eq(user.active, true),
or(
gt(user.age, 25),
eq(user.role, 'admin')
)
)
)
)
The query system provides several comparison operators:
import { eq, gt, gte, lt, lte, like, ilike, inArray, and, or, not } from '@tanstack/db'
// Equality
eq(user.id, 1)
// Comparisons
gt(user.age, 18) // greater than
gte(user.age, 18) // greater than or equal
lt(user.age, 65) // less than
lte(user.age, 65) // less than or equal
// String matching
like(user.name, 'John%') // case-sensitive pattern matching
ilike(user.name, 'john%') // case-insensitive pattern matching
// Array membership
inArray(user.id, [1, 2, 3])
// Logical operators
and(condition1, condition2)
or(condition1, condition2)
not(condition)
import { eq, gt, gte, lt, lte, like, ilike, inArray, and, or, not } from '@tanstack/db'
// Equality
eq(user.id, 1)
// Comparisons
gt(user.age, 18) // greater than
gte(user.age, 18) // greater than or equal
lt(user.age, 65) // less than
lte(user.age, 65) // less than or equal
// String matching
like(user.name, 'John%') // case-sensitive pattern matching
ilike(user.name, 'john%') // case-insensitive pattern matching
// Array membership
inArray(user.id, [1, 2, 3])
// Logical operators
and(condition1, condition2)
or(condition1, condition2)
not(condition)
For a complete reference of all available functions, see the Expression Functions Reference section.
Use select to specify which fields to include in your results and transform your data. Without select, you get the full schema.
Similar to the where clause, the select method takes a callback function that receives an object containing your table aliases and returns an object with the fields you want to include in your results. These can be combined with functions from the Expression Functions Reference section to create computed fields. You can also use the spread operator to include all fields from a table.
select(
projection: (row: TRow) => Record<string, Expression>
): Query
select(
projection: (row: TRow) => Record<string, Expression>
): Query
Parameters:
Select specific fields from your data:
const userNames = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
id: user.id,
name: user.name,
email: user.email,
}))
)
/*
Result type: { id: number, name: string, email: string }
```ts
for (const row of userNames) {
console.log(row.name)
}
```
*/
const userNames = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
id: user.id,
name: user.name,
email: user.email,
}))
)
/*
Result type: { id: number, name: string, email: string }
```ts
for (const row of userNames) {
console.log(row.name)
}
```
*/
Rename fields in your results:
const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
userId: user.id,
fullName: user.name,
contactEmail: user.email,
}))
)
const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
userId: user.id,
fullName: user.name,
contactEmail: user.email,
}))
)
Create computed fields using expressions:
import { gt, length } from '@tanstack/db'
const userStats = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
id: user.id,
name: user.name,
isAdult: gt(user.age, 18),
nameLength: length(user.name),
}))
)
import { gt, length } from '@tanstack/db'
const userStats = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
id: user.id,
name: user.name,
isAdult: gt(user.age, 18),
nameLength: length(user.name),
}))
)
Transform your data using built-in functions:
import { concat, upper, gt } from '@tanstack/db'
const formattedUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
...user, // Include all user fields
displayName: upper(concat(user.firstName, ' ', user.lastName)),
isAdult: gt(user.age, 18),
}))
}))
/*
Result type:
{
id: number,
name: string,
email: string,
displayName: string,
isAdult: boolean,
}
*/
import { concat, upper, gt } from '@tanstack/db'
const formattedUsers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
...user, // Include all user fields
displayName: upper(concat(user.firstName, ' ', user.lastName)),
isAdult: gt(user.age, 18),
}))
}))
/*
Result type:
{
id: number,
name: string,
email: string,
displayName: string,
isAdult: boolean,
}
*/
For a complete list of available functions, see the Expression Functions Reference section.
Use join to combine data from multiple collections. Joins default to left join type and only support equality conditions.
Joins in TanStack DB are a way to combine data from multiple collections, and are conceptually very similar to SQL joins. When two collections are joined, the result is a new collection that contains the combined data as single rows. The new collection is a live query collection, and will automatically update when the underlying data changes.
A join without a select will return row objects that are namespaced with the aliases of the joined collections.
The result type of a join will take into account the join type, with the optionality of the joined fields being determined by the join type.
Note
We are working on an include system that will enable joins that project to a hierarchical object. For example an issue row could have a comments property that is an array of comment rows. See this issue for more details.
join(
{ [alias]: Collection | Query },
condition: (row: TRow) => Expression<boolean>, // Must be an `eq` condition
joinType?: 'left' | 'right' | 'inner' | 'full'
): Query
join(
{ [alias]: Collection | Query },
condition: (row: TRow) => Expression<boolean>, // Must be an `eq` condition
joinType?: 'left' | 'right' | 'inner' | 'full'
): Query
Parameters:
Join users with their posts:
const userPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
)
/*
Result type:
{
user: User,
post?: Post, // post is optional because it is a left join
}
```ts
for (const row of userPosts) {
console.log(row.user.name, row.post?.title)
}
```
*/
const userPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
)
/*
Result type:
{
user: User,
post?: Post, // post is optional because it is a left join
}
```ts
for (const row of userPosts) {
console.log(row.user.name, row.post?.title)
}
```
*/
Specify the join type as the third parameter:
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
'inner', // `inner`, `left`, `right` or `full`
)
)
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
'inner', // `inner`, `left`, `right` or `full`
)
)
Or using the aliases leftJoin, rightJoin, innerJoin and fullJoin methods:
// Left join - all users, even without posts
const allUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.leftJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user: User,
post?: Post, // post is optional because it is a left join
}
*/
// Left join - all users, even without posts
const allUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.leftJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user: User,
post?: Post, // post is optional because it is a left join
}
*/
// Right join - all posts, even without users
const allPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.rightJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user?: User, // user is optional because it is a right join
post: Post,
}
*/
// Right join - all posts, even without users
const allPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.rightJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user?: User, // user is optional because it is a right join
post: Post,
}
*/
// Inner join - only matching records
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.innerJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user: User,
post: Post,
}
*/
// Inner join - only matching records
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.innerJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user: User,
post: Post,
}
*/
// Full join - all users and all posts
const allUsersAndPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fullJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user?: User, // user is optional because it is a full join
post?: Post, // post is optional because it is a full join
}
*/
// Full join - all users and all posts
const allUsersAndPosts = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fullJoin(
{ post: postsCollection },
({ user, post }) => eq(user.id, post.userId),
)
)
/*
Result type:
{
user?: User, // user is optional because it is a full join
post?: Post, // post is optional because it is a full join
}
*/
Chain multiple joins in a single query:
const userPostComments = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.join({ comment: commentsCollection }, ({ post, comment }) =>
eq(post.id, comment.postId)
)
.select(({ user, post, comment }) => ({
userName: user.name,
postTitle: post.title,
commentText: comment.text,
}))
)
const userPostComments = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.join({ comment: commentsCollection }, ({ post, comment }) =>
eq(post.id, comment.postId)
)
.select(({ user, post, comment }) => ({
userName: user.name,
postTitle: post.title,
commentText: comment.text,
}))
)
Subqueries allow you to use the result of one query as input to another, they are embedded within the query itself and are compile to a single query pipeline. They are very similar to SQL subqueries that are executed as part of a single operation.
Note that subqueries are not the same as using a live query result in a from or join clause in a new query. When you do that the intermediate result is fully computed and accessible to you, subqueries are internal to their parent query and not materialised to a collection themselves and so are more efficient.
See the Caching Intermediate Results section for more details on using live query results in a from or join clause in a new query.
Use a subquery as the main source:
const activeUserPosts = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery first
const activeUsers = q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
// Use the subquery in the main query
return q
.from({ activeUser: activeUsers })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
}
}))
const activeUserPosts = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery first
const activeUsers = q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
// Use the subquery in the main query
return q
.from({ activeUser: activeUsers })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
}
}))
Join with a subquery result:
const userRecentPosts = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery first
const recentPosts = q
.from({ post: postsCollection })
.where(({ post }) => gt(post.createdAt, '2024-01-01'))
.orderBy(({ post }) => post.createdAt, 'desc')
.limit(1)
// Use the subquery in the main query
return q
.from({ user: usersCollection })
.join({ recentPost: recentPosts }, ({ user, recentPost }) =>
eq(user.id, recentPost.userId)
)
}
}))
const userRecentPosts = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery first
const recentPosts = q
.from({ post: postsCollection })
.where(({ post }) => gt(post.createdAt, '2024-01-01'))
.orderBy(({ post }) => post.createdAt, 'desc')
.limit(1)
// Use the subquery in the main query
return q
.from({ user: usersCollection })
.join({ recentPost: recentPosts }, ({ user, recentPost }) =>
eq(user.id, recentPost.userId)
)
}
}))
When the same subquery is used multiple times within a query, it's automatically deduplicated and executed only once:
const complexQuery = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery once
const activeUsers = q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
// Use the same subquery multiple times
return q
.from({ activeUser: activeUsers })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
.join({ comment: commentsCollection }, ({ activeUser, comment }) =>
eq(activeUser.id, comment.userId)
)
}
}))
const complexQuery = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the subquery once
const activeUsers = q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
// Use the same subquery multiple times
return q
.from({ activeUser: activeUsers })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
.join({ comment: commentsCollection }, ({ activeUser, comment }) =>
eq(activeUser.id, comment.userId)
)
}
}))
In this example, the activeUsers subquery is used twice but executed only once, improving performance.
Build complex queries with multiple levels of nesting:
import { count } from '@tanstack/db'
const topUsers = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the post count subquery
const postCounts = q
.from({ post: postsCollection })
.groupBy(({ post }) => post.userId)
.select(({ post }) => ({
userId: post.userId,
count: count(post.id),
}))
// Build the user stats subquery
const userStats = q
.from({ user: usersCollection })
.join({ postCount: postCounts }, ({ user, postCount }) =>
eq(user.id, postCount.userId)
)
.select(({ user, postCount }) => ({
id: user.id,
name: user.name,
postCount: postCount.count,
}))
.orderBy(({ userStats }) => userStats.postCount, 'desc')
.limit(10)
// Use the user stats subquery in the main query
return q.from({ userStats })
}
}))
import { count } from '@tanstack/db'
const topUsers = createCollection(liveQueryCollectionOptions({
query: (q) => {
// Build the post count subquery
const postCounts = q
.from({ post: postsCollection })
.groupBy(({ post }) => post.userId)
.select(({ post }) => ({
userId: post.userId,
count: count(post.id),
}))
// Build the user stats subquery
const userStats = q
.from({ user: usersCollection })
.join({ postCount: postCounts }, ({ user, postCount }) =>
eq(user.id, postCount.userId)
)
.select(({ user, postCount }) => ({
id: user.id,
name: user.name,
postCount: postCount.count,
}))
.orderBy(({ userStats }) => userStats.postCount, 'desc')
.limit(10)
// Use the user stats subquery in the main query
return q.from({ userStats })
}
}))
Use groupBy to group your data and apply aggregate functions. When you use aggregates in select without groupBy, the entire result set is treated as a single group.
groupBy(
grouper: (row: TRow) => Expression | Expression[]
): Query
groupBy(
grouper: (row: TRow) => Expression | Expression[]
): Query
Parameters:
Group users by their department and count them:
import { count, avg } from '@tanstack/db'
const departmentStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => user.departmentId)
.select(({ user }) => ({
departmentId: user.departmentId,
userCount: count(user.id),
avgAge: avg(user.age),
}))
}))
import { count, avg } from '@tanstack/db'
const departmentStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => user.departmentId)
.select(({ user }) => ({
departmentId: user.departmentId,
userCount: count(user.id),
avgAge: avg(user.age),
}))
}))
Note
In groupBy queries, the properties in your select clause must either be:
You cannot select properties that are neither aggregated nor grouped.
Group by multiple columns by returning an array from the callback:
const userStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => [user.departmentId, user.role])
.select(({ user }) => ({
departmentId: user.departmentId,
role: user.role,
count: count(user.id),
avgSalary: avg(user.salary),
}))
}))
const userStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => [user.departmentId, user.role])
.select(({ user }) => ({
departmentId: user.departmentId,
role: user.role,
count: count(user.id),
avgSalary: avg(user.salary),
}))
}))
Use various aggregate functions to summarize your data:
import { count, sum, avg, min, max } from '@tanstack/db'
const orderStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalOrders: count(order.id),
totalAmount: sum(order.amount),
avgOrderValue: avg(order.amount),
minOrder: min(order.amount),
maxOrder: max(order.amount),
}))
}))
import { count, sum, avg, min, max } from '@tanstack/db'
const orderStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalOrders: count(order.id),
totalAmount: sum(order.amount),
avgOrderValue: avg(order.amount),
minOrder: min(order.amount),
maxOrder: max(order.amount),
}))
}))
See the Aggregate Functions section for a complete list of available aggregate functions.
Filter aggregated results using having - this is similar to the where clause, but is applied after the aggregation has been performed.
having(
condition: (row: TRow) => Expression<boolean>
): Query
having(
condition: (row: TRow) => Expression<boolean>
): Query
Parameters:
const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.having(({ order }) => gt(sum(order.amount), 1000))
)
const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.having(({ order }) => gt(sum(order.amount), 1000))
)
When you use aggregates without groupBy, the entire result set is grouped:
const overallStats = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
totalUsers: count(user.id),
avgAge: avg(user.age),
maxSalary: max(user.salary),
}))
)
const overallStats = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.select(({ user }) => ({
totalUsers: count(user.id),
avgAge: avg(user.age),
maxSalary: max(user.salary),
}))
)
This is equivalent to grouping the entire collection into a single group.
Grouped results can be accessed by the group key:
const deptStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => user.departmentId)
.select(({ user }) => ({
departmentId: user.departmentId,
count: count(user.id),
}))
}))
// Access by department ID
const engineeringStats = deptStats.get(1)
const deptStats = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.groupBy(({ user }) => user.departmentId)
.select(({ user }) => ({
departmentId: user.departmentId,
count: count(user.id),
}))
}))
// Access by department ID
const engineeringStats = deptStats.get(1)
Note: Grouped results are keyed differently based on the grouping:
- Single column grouping: Keyed by the actual value (e.g., deptStats.get(1))
- Multiple column grouping: Keyed by a JSON string of the grouped values (e.g., userStats.get('[1,"admin"]'))
Use orderBy, limit, and offset to control the order and pagination of your results. Ordering is performed incrementally for optimal performance.
orderBy(
selector: (row: TRow) => Expression,
direction?: 'asc' | 'desc'
): Query
limit(count: number): Query
offset(count: number): Query
orderBy(
selector: (row: TRow) => Expression,
direction?: 'asc' | 'desc'
): Query
limit(count: number): Query
offset(count: number): Query
Parameters:
Sort results by a single column:
const sortedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.name)
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)
const sortedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.name)
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)
Order by multiple columns:
const sortedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.departmentId, 'asc')
.orderBy(({ user }) => user.name, 'asc')
.select(({ user }) => ({
id: user.id,
name: user.name,
departmentId: user.departmentId,
}))
)
const sortedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.departmentId, 'asc')
.orderBy(({ user }) => user.name, 'asc')
.select(({ user }) => ({
id: user.id,
name: user.name,
departmentId: user.departmentId,
}))
)
Use desc for descending order:
const recentPosts = createLiveQueryCollection((q) =>
q
.from({ post: postsCollection })
.orderBy(({ post }) => post.createdAt, 'desc')
.select(({ post }) => ({
id: post.id,
title: post.title,
createdAt: post.createdAt,
}))
)
const recentPosts = createLiveQueryCollection((q) =>
q
.from({ post: postsCollection })
.orderBy(({ post }) => post.createdAt, 'desc')
.select(({ post }) => ({
id: post.id,
title: post.title,
createdAt: post.createdAt,
}))
)
Skip results using offset:
const page2Users = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.name, 'asc')
.limit(20)
.offset(20) // Skip first 20 results
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)
const page2Users = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.orderBy(({ user }) => user.name, 'asc')
.limit(20)
.offset(20) // Skip first 20 results
.select(({ user }) => ({
id: user.id,
name: user.name,
}))
)
Build complex queries by composing smaller, reusable parts. This approach makes your queries more maintainable and allows for better performance through caching.
Build queries based on runtime conditions:
import { Query, eq } from '@tanstack/db'
function buildUserQuery(options: { activeOnly?: boolean; limit?: number }) {
let query = new Query().from({ user: usersCollection })
if (options.activeOnly) {
query = query.where(({ user }) => eq(user.active, true))
}
if (options.limit) {
query = query.limit(options.limit)
}
return query.select(({ user }) => ({
id: user.id,
name: user.name,
}))
}
const activeUsers = createLiveQueryCollection(buildUserQuery({ activeOnly: true, limit: 10 }))
import { Query, eq } from '@tanstack/db'
function buildUserQuery(options: { activeOnly?: boolean; limit?: number }) {
let query = new Query().from({ user: usersCollection })
if (options.activeOnly) {
query = query.where(({ user }) => eq(user.active, true))
}
if (options.limit) {
query = query.limit(options.limit)
}
return query.select(({ user }) => ({
id: user.id,
name: user.name,
}))
}
const activeUsers = createLiveQueryCollection(buildUserQuery({ activeOnly: true, limit: 10 }))
The result of a live query collection is a collection itself, and will automatically update when the underlying data changes. This means that you can use the result of a live query collection as a source in another live query collection. This pattern is useful for building complex queries where you want to cache intermediate results to make further queries faster.
// Base query for active users
const activeUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)
// Query that depends on active users
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: activeUsers })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.select(({ user, post }) => ({
userName: user.name,
postTitle: post.title,
}))
)
// Base query for active users
const activeUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
)
// Query that depends on active users
const activeUserPosts = createLiveQueryCollection((q) =>
q
.from({ user: activeUsers })
.join({ post: postsCollection }, ({ user, post }) =>
eq(user.id, post.userId)
)
.select(({ user, post }) => ({
userName: user.name,
postTitle: post.title,
}))
)
You can use the Query class to create reusable query definitions. This is useful for building complex queries where you want to reuse the same query builder instance multiple times throughout your application.
import { Query, eq } from '@tanstack/db'
// Create a reusable query builder
const userQuery = new Query()
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
// Use it in different contexts
const activeUsers = createLiveQueryCollection({
query: userQuery.select(({ user }) => ({
id: user.id,
name: user.name,
}))
})
// Or as a subquery
const userPosts = createLiveQueryCollection((q) =>
q
.from({ activeUser: userQuery })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
)
import { Query, eq } from '@tanstack/db'
// Create a reusable query builder
const userQuery = new Query()
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true))
// Use it in different contexts
const activeUsers = createLiveQueryCollection({
query: userQuery.select(({ user }) => ({
id: user.id,
name: user.name,
}))
})
// Or as a subquery
const userPosts = createLiveQueryCollection((q) =>
q
.from({ activeUser: userQuery })
.join({ post: postsCollection }, ({ activeUser, post }) =>
eq(activeUser.id, post.userId)
)
)
Use Ref<MyType> to create reusable callback functions:
import { Ref, eq, gt, and } from '@tanstack/db'
// Create reusable callbacks
const isActiveUser = (user: Ref<User>) => eq(user.active, true)
const isAdultUser = (user: Ref<User>) => gt(user.age, 18)
// Use them in queries
const activeAdults = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => and(isActiveUser(user), isAdultUser(user)))
.select(({ user }) => ({
id: user.id,
name: user.name,
age: user.age,
}))
}))
import { Ref, eq, gt, and } from '@tanstack/db'
// Create reusable callbacks
const isActiveUser = (user: Ref<User>) => eq(user.active, true)
const isAdultUser = (user: Ref<User>) => gt(user.age, 18)
// Use them in queries
const activeAdults = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => and(isActiveUser(user), isAdultUser(user)))
.select(({ user }) => ({
id: user.id,
name: user.name,
age: user.age,
}))
}))
You can also create callbacks that take the whole row and pass them directly to where:
// Callback that takes the whole row
const isHighValueCustomer = (row: { user: User; order: Order }) =>
row.user.active && row.order.amount > 1000
// Use directly in where clause
const highValueCustomers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.join({ order: ordersCollection }, ({ user, order }) =>
eq(user.id, order.userId)
)
.where(isHighValueCustomer)
.select(({ user, order }) => ({
userName: user.name,
orderAmount: order.amount,
}))
}))
// Callback that takes the whole row
const isHighValueCustomer = (row: { user: User; order: Order }) =>
row.user.active && row.order.amount > 1000
// Use directly in where clause
const highValueCustomers = createCollection(liveQueryCollectionOptions({
query: (q) =>
q
.from({ user: usersCollection })
.join({ order: ordersCollection }, ({ user, order }) =>
eq(user.id, order.userId)
)
.where(isHighValueCustomer)
.select(({ user, order }) => ({
userName: user.name,
orderAmount: order.amount,
}))
}))
This approach makes your query logic more modular and testable.
The query system provides a comprehensive set of functions for filtering, transforming, and aggregating data.
Equality comparison:
eq(user.id, 1)
eq(user.name, 'John')
eq(user.id, 1)
eq(user.name, 'John')
Numeric, string and date comparisons:
gt(user.age, 18)
gte(user.salary, 50000)
lt(user.createdAt, new Date('2024-01-01'))
lte(user.rating, 5)
gt(user.age, 18)
gte(user.salary, 50000)
lt(user.createdAt, new Date('2024-01-01'))
lte(user.rating, 5)
Check if a value is in an array:
inArray(user.id, [1, 2, 3])
inArray(user.role, ['admin', 'moderator'])
inArray(user.id, [1, 2, 3])
inArray(user.role, ['admin', 'moderator'])
String pattern matching:
like(user.name, 'John%') // Case-sensitive
ilike(user.email, '%@gmail.com') // Case-insensitive
like(user.name, 'John%') // Case-sensitive
ilike(user.email, '%@gmail.com') // Case-insensitive
Combine conditions with AND logic:
and(
eq(user.active, true),
gt(user.age, 18),
eq(user.role, 'user')
)
and(
eq(user.active, true),
gt(user.age, 18),
eq(user.role, 'user')
)
Combine conditions with OR logic:
or(
eq(user.role, 'admin'),
eq(user.role, 'moderator')
)
or(
eq(user.role, 'admin'),
eq(user.role, 'moderator')
)
Negate a condition:
not(eq(user.active, false))
not(eq(user.active, false))
Convert case:
upper(user.name) // 'JOHN'
lower(user.email) // 'john@example.com'
upper(user.name) // 'JOHN'
lower(user.email) // 'john@example.com'
Get string or array length:
length(user.name) // String length
length(user.tags) // Array length
length(user.name) // String length
length(user.tags) // Array length
Concatenate strings:
concat(user.firstName, ' ', user.lastName)
concat('User: ', user.name, ' (', user.id, ')')
concat(user.firstName, ' ', user.lastName)
concat('User: ', user.name, ' (', user.id, ')')
Add two numbers:
add(user.salary, user.bonus)
add(user.salary, user.bonus)
Return the first non-null value:
coalesce(user.displayName, user.name, 'Unknown')
coalesce(user.displayName, user.name, 'Unknown')
Count non-null values:
count(user.id) // Count all users
count(user.postId) // Count users with posts
count(user.id) // Count all users
count(user.postId) // Count users with posts
Sum numeric values:
sum(order.amount)
sum(user.salary)
sum(order.amount)
sum(user.salary)
Calculate average:
avg(user.salary)
avg(order.amount)
avg(user.salary)
avg(order.amount)
Find minimum and maximum values:
min(user.salary)
max(order.amount)
min(user.salary)
max(order.amount)
Functions can be composed and chained:
// Complex condition
and(
eq(user.active, true),
or(
gt(user.age, 25),
eq(user.role, 'admin')
),
not(inArray(user.id, bannedUserIds))
)
// Complex transformation
concat(
upper(user.firstName),
' ',
upper(user.lastName),
' (',
user.id,
')'
)
// Complex aggregation
avg(add(user.salary, coalesce(user.bonus, 0)))
// Complex condition
and(
eq(user.active, true),
or(
gt(user.age, 25),
eq(user.role, 'admin')
),
not(inArray(user.id, bannedUserIds))
)
// Complex transformation
concat(
upper(user.firstName),
' ',
upper(user.lastName),
' (',
user.id,
')'
)
// Complex aggregation
avg(add(user.salary, coalesce(user.bonus, 0)))
The functional variant API provides an alternative to the standard API, offering more flexibility for complex transformations. With functional variants, the callback functions contain actual code that gets executed to perform the operation, giving you the full power of JavaScript at your disposal.
Warning
The functional variant API cannot be optimized by the query optimizer or use collection indexes. It is intended for use in rare cases where the standard API is not sufficient.
Use fn.select() for complex transformations with JavaScript logic:
const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row) => ({
id: row.user.id,
displayName: `${row.user.firstName} ${row.user.lastName}`,
salaryTier: row.user.salary > 100000 ? 'senior' : 'junior',
emailDomain: row.user.email.split('@')[1],
isHighEarner: row.user.salary > 75000,
}))
)
const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row) => ({
id: row.user.id,
displayName: `${row.user.firstName} ${row.user.lastName}`,
salaryTier: row.user.salary > 100000 ? 'senior' : 'junior',
emailDomain: row.user.email.split('@')[1],
isHighEarner: row.user.salary > 75000,
}))
)
Use fn.where() for complex filtering logic:
const specialUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.where((row) => {
const user = row.user
return user.active &&
(user.age > 25 || user.role === 'admin') &&
user.email.includes('@company.com')
})
)
const specialUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.where((row) => {
const user = row.user
return user.active &&
(user.age > 25 || user.role === 'admin') &&
user.email.includes('@company.com')
})
)
Use fn.having() for complex aggregation filtering:
const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.fn.having((row) => {
return row.totalSpent > 1000 && row.orderCount >= 3
})
)
const highValueCustomers = createLiveQueryCollection((q) =>
q
.from({ order: ordersCollection })
.groupBy(({ order }) => order.customerId)
.select(({ order }) => ({
customerId: order.customerId,
totalSpent: sum(order.amount),
orderCount: count(order.id),
}))
.fn.having((row) => {
return row.totalSpent > 1000 && row.orderCount >= 3
})
)
Functional variants excel at complex data transformations:
const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row) => {
const user = row.user
const fullName = `${user.firstName} ${user.lastName}`.trim()
const emailDomain = user.email.split('@')[1]
const ageGroup = user.age < 25 ? 'young' : user.age < 50 ? 'adult' : 'senior'
return {
userId: user.id,
displayName: fullName || user.name,
contactInfo: {
email: user.email,
domain: emailDomain,
isCompanyEmail: emailDomain === 'company.com'
},
demographics: {
age: user.age,
ageGroup: ageGroup,
isAdult: user.age >= 18
},
status: user.active ? 'active' : 'inactive',
profileStrength: fullName && user.email && user.age ? 'complete' : 'incomplete'
}
})
)
const userProfiles = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row) => {
const user = row.user
const fullName = `${user.firstName} ${user.lastName}`.trim()
const emailDomain = user.email.split('@')[1]
const ageGroup = user.age < 25 ? 'young' : user.age < 50 ? 'adult' : 'senior'
return {
userId: user.id,
displayName: fullName || user.name,
contactInfo: {
email: user.email,
domain: emailDomain,
isCompanyEmail: emailDomain === 'company.com'
},
demographics: {
age: user.age,
ageGroup: ageGroup,
isAdult: user.age >= 18
},
status: user.active ? 'active' : 'inactive',
profileStrength: fullName && user.email && user.age ? 'complete' : 'incomplete'
}
})
)
Functional variants maintain full TypeScript support:
const processedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row): ProcessedUser => ({
id: row.user.id,
name: row.user.name.toUpperCase(),
age: row.user.age,
ageGroup: row.user.age < 25 ? 'young' : row.user.age < 50 ? 'adult' : 'senior',
}))
)
const processedUsers = createLiveQueryCollection((q) =>
q
.from({ user: usersCollection })
.fn.select((row): ProcessedUser => ({
id: row.user.id,
name: row.user.name.toUpperCase(),
age: row.user.age,
ageGroup: row.user.age < 25 ? 'young' : row.user.age < 50 ? 'adult' : 'senior',
}))
)
Use functional variants when you need:
The callbacks in functional variants are actual JavaScript functions that get executed, unlike the standard API which uses declarative expressions. This gives you complete control over the logic but comes with the trade-off of reduced optimization opportunities.
However, prefer the standard API when possible, as it provides better performance and optimization opportunities.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.