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
export default noboil({ tables: ({ table }) => ({
blog: table(s.blog, { pub: 'published' }),
project: table(s.project)
}) })pub: 'published' generates WHERE published = true OR userId = :sender and indexes the published column. No pub means owner-only (WHERE userId = :sender).
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' })Client-side search via useList:
const [query, setQuery] = useState('')
const { items, totalCount } = useList(posts, isReady, {
search: { query, fields: ['title', 'content'], debounceMs: 300 },
where: { published: true },
sort: { field: 'updatedAt', direction: 'desc' },
})debounceMs: 300 delays the search filter until the user stops typing.
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.
SpacetimeDB exposes an HTTP SQL endpoint. Ideal for Next.js Server Components where you can't use WebSockets:
const STDB_URI = process.env.SPACETIMEDB_URI ?? 'http://localhost:4200'
const MODULE = process.env.MODULE_NAME ?? 'my-app'
const fetchPosts = async () => {
const res = await fetch(`${STDB_URI}/v1/database/${MODULE}/sql`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: 'SELECT id, title, content, published FROM post WHERE published = true ORDER BY id DESC LIMIT 20',
cache: 'no-store',
})
if (!res.ok) throw new Error('Failed to fetch posts')
const [result] = await res.json() as [{ rows: [number, string, string, boolean][] }]
return result.rows.map(([id, title, content, published]) => ({ id, title, content, published }))
}
const PostsPage = async () => {
const posts = await fetchPosts()
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}Latency is ~0.27ms for simple queries on local Docker.
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))import { useOwnRows } from 'noboil/spacetimedb/react'
const { identity } = useSpacetimeDB()
const ownedPosts = useOwnRows(posts, identity ? p => p.userId.isEqual(identity) : 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.
table(s.movie, { key: 'tmdbId' })Cache tables are public by design — no RLS filter is applied. Populate the cache from a Next.js API route or a scheduled reducer that calls the external API and inserts rows. SpacetimeDB doesn't run external fetch() reliably from inside the module.
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.
SpacetimeDB is an in-memory database. Subscriptions don't support LIMIT, OFFSET, or ORDER BY server-side — sort and paginate client-side with useList. The chunk pattern keeps client memory bounded:
| MMO concept | App equivalent |
|---|---|
| Spatial chunk | Time window (last 24h, last 7d) |
| Chunk loading | Subscribe to next time window on scroll |
| Chunk unloading | Unsubscribe from old window to free client memory |
Use warnLargeFilterSet to catch unbounded data patterns during development:
import { warnLargeFilterSet } from 'noboil/spacetimedb/server'
warnLargeFilterSet(rows.length, 'posts', 'home-feed')
warnLargeFilterSet(rows.length, 'posts', 'home-feed', true) // strict mode (throw)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' />