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

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' })

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

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.

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