Data Fetching
Queries, subscriptions, pagination, filtering, and SSR patterns.
Reactive subscriptions out of the box. Filter, sort, paginate, and search through one hook: useList.
Access control on read endpoints
Pass pub on a table to control what unauthenticated callers can see.
const api = noboil({ ...config, tables: ({ table }) => ({
blog: table(s.blog, {
pub: { where: { published: true } }
})
}) })The crud()-style returns destructure into { pub, auth }:
// convex/blog.ts
import { api } from '../lazy'
const { create, update, rm, pub: { list, read } } = api.blog
export { create, list, read, rm, update }pub.list/pub.read— public, no auth requiredauth.list/auth.read— auth required, scoped to the caller's rows
Basic subscriptions
useList handles filtering, sorting, search, and pagination from one call.
'use client'
import { useList } from 'noboil/convex/react'
const Posts = () => {
const { items, hasMore, loadMore, isLoading } = useList(api.blog.list, {
where: { published: true },
pageSize: 20
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{items.map(p => <li key={p._id}>{p.title}</li>)}
{hasMore && <button onClick={loadMore}>Load more</button>}
</ul>
)
}The shape is the same on both. SpacetimeDB users can also drop down to useTable directly for unbounded subscriptions:
import { useTable } from 'spacetimedb/react'
import { tables } from '@/generated/module_bindings'
import { useList } from 'noboil/spacetimedb/react'
const [posts, isReady] = useTable(tables.post)
const { data, hasMore, loadMore } = useList(posts, isReady, { pageSize: 20 })Where clauses and filtering
where accepts field equality, comparison operators ($gt, $gte, $lt, $lte, $between), the own: true shorthand, and or groups.
useList(api.blog.list, { where: { own: true } })
useList(api.blog.list, { where: { published: true, category: 'tech' } })
useList(api.blog.list, { where: { price: { $gte: 100 } } })
useList(api.blog.list, {
where: { or: [{ category: 'tech' }, { category: 'science' }] }
}){ own: true } uses an indexed query — it scales to millions of rows. Other where clauses are runtime filters that work well up to ~1,000 docs (Convex) / unbounded subscription size (SpacetimeDB).
Default where clauses can be set per-factory:
table(s.blog, { pub: { where: { published: true } } }) // Convex
table(s.blog, { pub: 'published' }) // SpacetimeDBFor complex client-side filtering on SpacetimeDB, subscribe to a wider set with tables.post.where(r => r.published.eq(true)) and refine with useList's where.
useList options
useList(query, {
where: {
published: true,
category: 'tech',
or: [{ category: 'news' }]
},
sort: { field: 'updatedAt', direction: 'desc' },
search: { query: 'draft', fields: ['title', 'content'] },
pageSize: 20,
page: 1
})Returns:
type UseListResult<T> = {
items: T[] // current page
data: T[] // alias for items (SpacetimeDB convention)
hasMore: boolean
isLoading: boolean
loadMore: () => void
page: number
totalCount: number
}On Convex this is a thin wrapper around usePaginatedQuery with cursor management. On SpacetimeDB it's pure client-side filtering over the subscription data.
Pagination patterns
Infinite scroll
const { items, hasMore, loadMore } = useList(api.blog.list, { where: { published: true } })
return (
<div>
<ul>{items.map(p => <li key={p._id}>{p.title}</li>)}</ul>
{hasMore && <button onClick={loadMore}>Load more</button>}
</div>
)For sentinel-driven infinite scroll, use useInfiniteList instead — it auto-loads when a sentinel element scrolls into view.
Controlled page
const [page, setPage] = useState(1)
const { items, totalCount } = useList(api.blog.list, {
where: { published: true },
pageSize: 20,
page
})
const totalPages = Math.ceil(totalCount / 20)Comparison operators
Same syntax on both:
useList(api.product.list, {
where: {
price: { $gte: 100 },
year: { $between: [2020, 2024] }
}
})Supported: $gt, $gte, $lt, $lte, $between.
Search
Server-side search via Convex search indexes. Generated only when search is configured on the table:
table(s.blog, { search: true })
table(s.blog, { search: 'content' })
table(s.blog, { search: { field: 'content', index: 'my_index' } })The string shorthand is typesafe — search: 'conten' is a compile error if conten isn't a field in your schema.
const results = useList(api.blog.search, { query: 'react hooks' })SSR
Server Components need a different transport on each backend. See Convex vs SpacetimeDB for the full breakdown.
Use preloadQuery in Server Components:
import { connection } from 'next/server'
import { preloadQuery } from 'convex/nextjs'
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'
import { api } from '../convex/_generated/api'
const PostsPage = async () => {
await connection()
const token = await convexAuthNextjsToken()
const preloaded = await preloadQuery(
api.blog.list,
{ where: { published: true } },
{ token }
)
return <PostsClient preloaded={preloaded} />
}await connection() must come first — it signals dynamic rendering before Convex calls Math.random() internally.
File uploads
file() for single file, files() for arrays. The form components handle the upload backend automatically — see File storage.
Soft delete and undo
Add softDelete: true to any table option to get a restore endpoint alongside rm. useSoftDelete wraps it in an undo-toast UX.
table(s.task, { softDelete: true })import { useSoftDelete } from 'noboil/convex/react'
const { softDelete } = useSoftDelete({
deleteMutation: api.task.rm,
restoreMutation: api.task.restore,
undoMs: 5000
})Filter active vs deleted rows using where:
const { items: active } = useList(api.task.list, { where: { deletedAt: undefined } })
const { items: deleted } = useList(api.task.list, { where: { deletedAt: { $gt: 0 } } })Bulk mutations with progress
useBulkMutate runs a list of mutations concurrently with progress tracking:
import { useBulkMutate } from 'noboil/convex/react'
const { execute, progress, isPending } = useBulkMutate({
mutation: api.blog.rm, // or mutate: id => removeBlog({ id })
onSuccess: () => toast.success('Deleted'),
onError: e => toast.error(e.message)
})
execute(selectedIds.map(id => ({ id })))Convex's bulk endpoints accept an items or ids array directly (capped at BULK_MAX = 100). SpacetimeDB processes one item per reducer call — useBulkMutate handles the loop.
Ownership-aware lists
useOwnRows annotates each row with an own: boolean flag. Useful for showing edit/delete buttons only on owned items.
import { useOwnRows } from 'noboil/convex/react'
const rows = useOwnRows(items, userId)
rows.map(r => (r.own ? <EditButton id={r._id} /> : null))Singleton CRUD
For 1:1 per-user data — profiles, settings, preferences. Each user gets exactly one record.
// schema (s.ts)
schema({
singleton: {
profile: object({ displayName: string(), bio: string().optional() })
}
})
// register
table(s.profile)Generated endpoints:
get— returns the current user's record (ornull)upsert— creates on first call, partial-updates on subsequent calls
await upsert({ displayName: 'Jane', theme: 'dark' })
await upsert({ bio: 'Updated bio' })Child CRUD
Child tables inherit access control from their parent.
schema({
children: {
message: child('chat', object({ text: string() }))
}
})
table(s.message, { pub: { parentField: 'isPublic' } })Parent ownership is verified automatically. Add cascade on the parent to cascade deletes:
table(s.chat, {
cascade: [{ foreignKey: 'chatId', table: 'message' }]
})For public access to child resources via the parent's pub field, see Recipes.
External API cache
base schemas + the cacheCrud factory (Convex) or key: table option (SpacetimeDB).
const c = cacheCrud({
table: 'movie',
schema: s.movie,
key: 'tmdb_id',
fetcher: async (_, tmdbId) => {
const { id, ...rest } = await tmdb(`/movie/${tmdbId}`).json<TmdbMovie>()
return { ...rest, tmdb_id: id }
},
rateLimit: { max: 30, window: 60_000 }
})load returns cached or fetches. refresh force-refreshes. purge cleans expired entries.
Rate limiting
Pass rateLimit on table options. See Security for the full reference.
table(s.blog, { rateLimit: { max: 10, window: 60_000 } }) // Convex full form
table(s.blog, { rateLimit: 10 }) // SpacetimeDB shorthandExceeding the limit returns RATE_LIMITED with a retryAfter value in milliseconds.
For per-resource sliding-window quotas (anti-spam, ballot-stuffing, API throttling), use the quota factory instead — it gives you check/record/consume triad and a useQuota() hook with reactive remaining count.
Append-only logs
For append-only data (chat messages, vote ballots, audit trails) use the log factory. The useLog() hook provides paginated rows + append/appendBulk/purge/restore helpers. Each log row gets an atomic per-parent seq and optional idempotency key.
import { useLog } from 'noboil/convex/react'
const log = useLog(api.vote, { parent: pollId })
await log.append({ payload: { optionIdx: 0, voter: 'me' } })
const counts = options.map((_, i) => log.data.filter(r => r.optionIdx === i).length)String-keyed global state
For state addressed by a known key (banner, feature flag, system status) use the kv factory. The useKv() hook subscribes to one key and returns data + update/remove/restore.
import { useKv } from 'noboil/convex/react'
const banner = useKv(api.siteConfig, 'banner')
await banner.update({ active: true, message: 'maintenance at 8pm' })
return banner.data?.active ? <Alert>{banner.data.message}</Alert> : nullPerformance and scaling
Where clauses ($gt, $lt, $between, or) use .filter() at the application level, not database indexes. This works well up to ~1,000 documents per table.
| Query pattern | Uses index? | Scales to |
|---|---|---|
{ own: true } | Yes (by_user) | Millions |
{ category: 'tech' } | No (runtime filter) | ~1,000 docs |
{ price: { $gte: 100 } } | No (runtime filter) | ~1,000 docs |
pubIndexed / authIndexed | Yes (custom index) | Millions |
Add Convex indexes and use pubIndexed/authIndexed:
blog: ownedTable(s.blog).index('by_category', ['category'])
const techPosts = useQuery(api.blog.pubIndexed, {
index: 'by_category',
key: 'category',
value: 'tech'
})Enable strictFilter: true in setup() to throw instead of warn when a filter set exceeds 1,000 docs.
Devtools
In dev mode, Devtools auto-mounts inside <Form>. To render it standalone:
import { Devtools } from 'noboil/convex/react'
<Devtools position='bottom-right' defaultTab='subs' />