noboil

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 required
  • auth.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' })                      // SpacetimeDB

For 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.

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 (or null)
  • 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 shorthand

Exceeding 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> : null

Performance 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 patternUses 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 / authIndexedYes (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 conceptApp equivalent
Spatial chunkTime window (last 24h, last 7d)
Chunk loadingSubscribe to next time window on scroll
Chunk unloadingUnsubscribe 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' />

On this page