Queries
Read data with defineQuery, execution modes, filters, ordering, and pagination.
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const incomplete = defineQuery({
args: { limit: v.optional(v.number()) },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('todos')
.filter((q) => q.eq('completed', false))
.order('createdAt', 'desc')
.take(args.limit ?? 50)
},
})A query reads data from the database. Define queries with defineQuery(), specifying arguments, an execution mode, and a handler. The handler receives a context object with a database reader (ctx.db) and the validated arguments.
Defining a query
Every query needs three properties:
args-- an object of validators that define the query's parametersexecution-- where the query runs:'local','server', or'fetch'handler-- an async function that receives(ctx, args)and returns data
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const byPriority = defineQuery({
args: { minPriority: v.number() },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('todos')
.filter((q) => q.gte('priority', args.minPriority))
.order('priority', 'asc')
.collect()
},
})Execution modes
The execution property controls where the query handler runs.
Local execution
// valet/todos.ts
import { defineQuery } from './_generated/valet/api'
export const list = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db.query('todos').collect()
},
})The handler runs against the client-side SQLite replica. Reads are instant with no network round-trip. Works offline. The query re-runs automatically when synced data changes.
Use local execution when:
- You need instant reads with no loading state
- The data is available on the client (controlled by sync filters)
- You want offline support
Server execution
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const search = defineQuery({
args: { term: v.string() },
execution: 'server',
handler: async (ctx, args) => {
return ctx.db
.query('todos')
.filter((q) => q.eq('title', args.term))
.collect()
},
})The handler runs on the server. The client subscribes via WebSocket and receives live updates. Use server execution when you need the full dataset, cross-user queries, or data that is not synced to the client.
Fetch execution
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const stats = defineQuery({
args: {},
execution: 'fetch',
handler: async (ctx) => {
const todos = await ctx.db.query('todos').collect()
return {
total: todos.length,
completed: todos.filter((t) => t.completed).length,
}
},
})The handler runs on the server as a one-shot HTTP request. No WebSocket subscription, no live updates. Use fetch execution for data you read once and do not need to keep in sync (dashboards, reports, exports).
Query builder
The database reader (ctx.db) provides a query builder for reading documents from a table. Start with ctx.db.query("tableName") and chain methods to filter, order, and limit results.
Collecting results
.collect() returns all matching documents as an array:
// valet/todos.ts
import { defineQuery } from './_generated/valet/api'
export const all = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db.query('todos').collect()
},
})First document
.first() returns the first matching document or null:
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const oldest = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db
.query('todos')
.order('createdAt', 'asc')
.first()
},
})Unique document
.unique() returns the single matching document. Throws if zero or more than one document matches:
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const byEmail = defineQuery({
args: { email: v.string() },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('users')
.filter((q) => q.eq('email', args.email))
.unique()
},
})Ordering
.order(field, direction) sets the sort field and direction. The direction parameter defaults to 'asc' if omitted:
// valet/todos.ts
import { defineQuery } from './_generated/valet/api'
export const recent = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db
.query('todos')
.order('createdAt', 'desc')
.take(10)
},
})Limiting results
.take(n) returns at most n documents. .skip(n) skips the first n documents:
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const page = defineQuery({
args: { offset: v.number(), limit: v.number() },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('todos')
.order('createdAt', 'desc')
.skip(args.offset)
.take(args.limit)
},
})Filters
The .filter() method accepts a callback that receives a filter builder q. The filter builder provides comparison operators that return filter expressions.
Comparison operators
| Operator | Description |
|---|---|
q.eq(field, v) | Field equals value |
q.neq(field, v) | Field does not equal value |
q.lt(field, v) | Field is less than value |
q.lte(field, v) | Field is less than or equal to value |
q.gt(field, v) | Field is greater than value |
q.gte(field, v) | Field is greater than or equal to value |
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const highPriority = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db
.query('todos')
.filter((q) => q.gte('priority', 8))
.collect()
},
})Collection operators
| Operator | Description |
|---|---|
q.oneOf(field, values) | Field is one of the given values |
q.arrayContains(field, v) | Array field contains value |
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const byStatus = defineQuery({
args: { statuses: v.array(v.string()) },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('todos')
.filter((q) => q.oneOf('status', args.statuses))
.collect()
},
})
export const taggedUrgent = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db
.query('todos')
.filter((q) => q.arrayContains('tags', 'urgent'))
.collect()
},
})Null checks
| Operator | Description |
|---|---|
q.isNull(field) | Field is null |
q.isNotNull(field) | Field is not null |
Logical operators
Combine filter expressions with q.and(), q.or(), and q.not():
// valet/todos.ts
import { defineQuery } from './_generated/valet/api'
export const activeHighPriority = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db
.query('todos')
.filter((q) =>
q.and(
q.eq('completed', false),
q.gte('priority', 8),
),
)
.collect()
},
})
export const urgentOrOverdue = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db
.query('todos')
.filter((q) =>
q.or(
q.gte('priority', 9),
q.lt('dueDate', Date.now()),
),
)
.collect()
},
})Pagination
Use .paginate() for cursor-based pagination:
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const paginated = defineQuery({
args: {
cursor: v.optional(v.string()),
pageSize: v.optional(v.number()),
},
execution: 'local',
handler: async (ctx, args) => {
return ctx.db
.query('todos')
.order('createdAt', 'desc')
.paginate({
cursor: args.cursor,
limit: args.pageSize ?? 20,
})
},
})The result includes a data array of documents, a nextCursor string for the next page, and a hasMore boolean indicating whether more documents exist.
Full-text search
Use ctx.db.search() with a search index:
// valet/articles.ts
import { defineQuery, v } from './_generated/valet/api'
export const search = defineQuery({
args: { term: v.string() },
execution: 'server',
handler: async (ctx, args) => {
return ctx.db
.search('articles', 'body', { query: args.term })
},
})Full-text search runs on the server because it requires the search index. Search queries must use execution: 'server' or execution: 'fetch' -- they cannot use execution: 'local' because the client-side SQLite replica does not have access to the search index.
Get by document ID
Use ctx.db.get() to fetch a single document by its _id:
// valet/todos.ts
import { defineQuery, v } from './_generated/valet/api'
export const get = defineQuery({
args: { id: v.id('todos') },
execution: 'local',
handler: async (ctx, args) => {
return ctx.db.get(args.id)
},
})Returns the document or null if not found.
Using queries in React
useQuery
useQuery subscribes to a query and re-renders when the data changes:
// TodoList.tsx
import { useQuery } from './_generated/valet/react'
import { api } from './_generated/valet/api'
function TodoList() {
const { data: todos, isLoading, error } = useQuery(api.todos.list, {})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{todos?.map((todo) => (
<li key={todo._id}>{todo.title}</li>
))}
</ul>
)
}The first argument is the query reference from the api object. The second argument is the args object matching the query's args validators.
The skip option
Pass skip: true to disable a query without removing it from the component. This is useful for conditional queries:
// TodoDetail.tsx
import { useQuery } from './_generated/valet/react'
import { api } from './_generated/valet/api'
function TodoDetail({ todoId }: { todoId: string | null }) {
const { data: todo } = useQuery(
api.todos.get,
{ id: todoId! },
{ skip: !todoId },
)
if (!todo) return null
return <div>{todo.title}</div>
}When skip is true, the query does not execute and returns undefined for data.
One-shot queries
For data you need once without a live subscription, use fetchQuery() or useFetchQuery():
// Report.tsx
import { useFetchQuery } from './_generated/valet/react'
import { api } from './_generated/valet/api'
function Report() {
const { data: stats } = useFetchQuery(api.todos.stats, {})
if (!stats) return <div>Loading...</div>
return <div>Completed: {stats.completed} / {stats.total}</div>
}fetchQuery() is the imperative equivalent for use outside React components:
// utils.ts
import { fetchQuery } from './_generated/valet/react'
import { api } from './_generated/valet/api'
async function getStats() {
const stats = await fetchQuery(api.todos.stats, {})
return stats
}