noboil

Components

Prebuilt UI components for forms, access control, editors, and multi-step wizards.

Prebuilt UI components for forms, access control, editors, error boundaries, and multi-step wizards.

Form System

useForm

Takes a Zod schema and returns typed form state. Field name props are checked at compile time. A typo is a type error.

import { useForm } from 'noboil/convex/components'

const form = useForm({
  schema: s.blog,
  onSubmit: async d => {
    await create(d)
    return d
  }
})
ParamTypeDescription
schemaZodObjectZod schema that drives field types and validation
onSubmit(data) => Promise<data>Called with validated data on submit
autoSave{ enabled: boolean, debounceMs: number }Optional auto-save config

useFormMutation

Wires a form directly to a server mutation, pre-fills values from an existing record, and surfaces server errors as field-level validation.

import { useFormMutation } from 'noboil/convex/components'
import { pickValues } from 'noboil/convex/zod'

const form = useFormMutation({
  mutation: api.blog.update,
  schema: s.blog,
  values: pickValues(s.blog, post),
  transform: d => ({ ...d, id: post._id }),
  onSuccess: () => toast.success('Saved')
})
ParamTypeDescription
mutationMutationReferenceMutation to call on submit
schemaZodObjectZod schema for field types and validation
valuesobjectInitial field values (use pickValues to extract from a doc)
transform(data) => objectTransform validated data before calling the mutation
onSuccess() => voidCalled after successful mutation
onError(error) => void | falseCalled on error. Pass false to suppress default toast
toaststring | { success?, error? }Toast shorthand — pass a string for the success message

Form

Render-prop component. Destructures typed field components from the render prop. Using the wrong field type for a field is a compile error.

import { Form } from 'noboil/convex/components'

<Form
  form={form}
  render={({ Text, Choose, Toggle, File, Submit }) => (
    <>
      <Text name="title" label="Title" />
      <Text name="content" label="Content" multiline />
      <Choose name="category" label="Category" />
      <Toggle name="published" label="Published" />
      <Submit>Create</Submit>
    </>
  )}
/>

name is checked against the schema at compile time. name='titl' is a type error. <Text name='published' /> is a type error when published is a boolean field.


Field Components

All 16 field components are available inside the Form render prop. Every field accepts label, placeholder, disabled, and className.

ComponentZod TypeRendersKey Props
Textstring()Input or textareaname, label, placeholder, multiline, asyncValidate, asyncDebounceMs, className
Numnumber()Number inputname, label, min, max, step, className
Chooseenum()Select dropdownname, label, className
Comboboxstring()Searchable dropdownname, label, options, className
MultiSelectarray(enum())Multi-selectname, label, className
Arrarray(string())Tag inputname, label, tagClassName, containerClassName, inputClassName
Filefile()File uploadname, label, accept, className
Filesfiles()Multi-file uploadname, label, accept, maxFiles, className
Datepickdate()Date pickername, label, className
Timepicktime stringTime pickername, label, className
Colorpickstring()Color pickername, label, className
Ratingnumber()Star ratingname, label, max, className
Slidernumber()Range slidername, label, min, max, step, className
Toggleboolean()Checkbox or switchname, label, className
SubmitSubmit buttonchildren, className
ErrError displayname
<Form
  form={form}
  render={({ Text, Num, Choose, Combobox, MultiSelect, Arr, File, Files, Datepick, Timepick, Colorpick, Rating, Slider, Toggle, Submit, Err }) => (
    <>
      <Text name="title" label="Title" />
      <Text name="bio" label="Bio" multiline />
      <Text name="slug" label="Slug" asyncValidate={checkSlug} asyncDebounceMs={500} />
      <Num name="price" label="Price" min={0} step={0.01} />
      <Choose name="category" label="Category" />
      <Combobox name="country" label="Country" options={countryOptions} />
      <MultiSelect name="tags" label="Tags" />
      <Arr name="keywords" label="Keywords" />
      <File name="avatar" label="Avatar" accept="image/*" />
      <Files name="attachments" label="Attachments" maxFiles={5} />
      <Datepick name="publishedAt" label="Publish Date" />
      <Timepick name="startTime" label="Start Time" />
      <Colorpick name="brandColor" label="Brand Color" />
      <Rating name="priority" label="Priority" max={5} />
      <Slider name="volume" label="Volume" min={0} max={100} step={1} />
      <Toggle name="published" label="Published" />
      <Err name="title" />
      <Submit>Save</Submit>
    </>
  )}
/>

The <File> and <Files> components handle the upload backend automatically — see File storage.


AutoSaveIndicator

Shows "Saved Xs ago" and updates in real time. Pass form.lastSaved from a useForm call with autoSave enabled.

import { AutoSaveIndicator } from 'noboil/convex/components'

const form = useForm({
  schema: s.blog,
  onSubmit: d => update({ id: post._id, ...d }),
  autoSave: { enabled: true, debounceMs: 1000 }
})

<AutoSaveIndicator lastSaved={form.lastSaved} />
PropTypeDescription
lastSavedDate | nullTimestamp of last successful save
classNamestringOptional class override

ConflictDialog

Automatic conflict resolution. When an update returns CONFLICT (stale expectedUpdatedAt), Form renders a built-in dialog with three options: overwrite, reload, or cancel. No extra UI code needed.

const form = useFormMutation({
  mutation: api.blog.update,
  schema: s.blog,
  values: pickValues(s.blog, post),
  transform: d => ({ ...d, id: post._id, expectedUpdatedAt: post.updatedAt }),
  onSuccess: () => toast.success('Saved')
})

<Form form={form} render={({ Text, Submit }) => (
  <>
    <Text name="title" label="Title" />
    <Submit>Save</Submit>
  </>
)} />

You can also render ConflictDialog standalone:

import { ConflictDialog } from 'noboil/convex/components'

<ConflictDialog
  open={hasConflict}
  onOverwrite={handleOverwrite}
  onReload={handleReload}
  onCancel={() => setHasConflict(false)}
/>
PropTypeDescription
openbooleanControls dialog visibility
onOverwrite() => voidForce the current changes through
onReload() => voidFetch the latest version
onCancel() => voidDiscard changes and close

StepForm

Multi-step wizard with per-step Zod schemas and compile-time field checking. defineSteps() returns a StepForm component and a useStepper hook bound to those steps.

import { defineSteps } from 'noboil/convex/components'
import { z } from 'zod/v4'

const profileStep = z.object({
  displayName: z.string().min(1),
  bio: z.string().optional(),
})

const orgStep = z.object({
  name: z.string().min(1),
  slug: z.string().min(1),
})

const preferencesStep = z.object({
  theme: z.enum(['light', 'dark', 'system']),
  notifications: z.boolean(),
})

const { StepForm, useStepper } = defineSteps(
  { id: 'profile', label: 'Profile', schema: profileStep },
  { id: 'org', label: 'Organization', schema: orgStep },
  { id: 'preferences', label: 'Preferences', schema: preferencesStep }
)

const OnboardingWizard = () => {
  const stepper = useStepper({
    onSubmit: async d => {
      await upsertProfile({ ...d.profile, ...d.preferences })
      await createOrg({ name: d.org.name, slug: d.org.slug })
    },
    onSuccess: () => router.push('/dashboard')
  })

  return (
    <StepForm stepper={stepper} submitLabel="Complete setup">
      <StepForm.Step id="profile" render={({ Text, File }) => (
        <>
          <Text name="displayName" label="Display name" />
          <Text name="bio" label="Bio" multiline />
          <File name="avatar" label="Avatar" accept="image/*" />
        </>
      )} />
      <StepForm.Step id="org" render={({ Text }) => (
        <>
          <Text name="name" label="Organization name" />
          <Text name="slug" label="URL slug" />
        </>
      )} />
      <StepForm.Step id="preferences" render={({ Choose, Toggle }) => (
        <>
          <Choose name="theme" label="Theme" />
          <Toggle name="notifications" label="Email notifications" />
        </>
      )} />
    </StepForm>
  )
}

name='displayName' compiles on the profile step but is a type error on the org step.

Step indicator styling

Customize without forking the component:

<StepForm
  stepper={stepper}
  submitLabel="Complete setup"
  stepIndicatorClassNames={{
    nav: 'gap-3',
    step: 'gap-2',
    button: 'size-9',
    label: 'text-xs tracking-wide',
    separator: 'bg-primary/20'
  }}
>
  ...
</StepForm>
stepIndicatorClassNames keyTargets
navThe <nav> wrapping all steps
stepEach step container
buttonThe step number button
labelThe step label text
separatorThe line between steps

EditorsSection

Manages a list of editors for a resource. Shows current editors, lets you add members, and remove existing editors.

import { EditorsSection } from 'noboil/convex/components'

<EditorsSection
  editorsList={editors}
  members={orgMembers}
  onAdd={userId => addEditor({ id: doc._id, userId })}
  onRemove={userId => removeEditor({ id: doc._id, userId })}
  contentClassName="rounded-md border"
  emptyClassName="text-muted-foreground italic"
  headerClassName="pb-2 font-medium"
  itemClassName="px-2 py-1"
  triggerClassName="w-48"
/>
PropTypeDescription
editorsListEditor[]Current editors
membersMember[]All org members (for the add dropdown)
onAdd(userId: string) => voidCalled when a member is added as editor
onRemove(userId: string) => voidCalled when an editor is removed
contentClassNamestringClass for the editors list container
emptyClassNamestringClass for the empty state message
headerClassNamestringClass for the section header
itemClassNamestringClass for each editor row
triggerClassNamestringClass for the add-editor dropdown trigger

PermissionGuard

Wraps content that requires edit access. Renders children when canAccess is true, otherwise shows a "View only" fallback or a custom fallback node.

import { PermissionGuard } from 'noboil/convex/components'

<PermissionGuard canAccess={isOwner || isEditor}>
  <EditForm post={post} />
</PermissionGuard>

<PermissionGuard
  canAccess={isOwner}
  fallback={<p className="text-muted-foreground text-sm">Only owners can change this setting.</p>}
>
  <DangerZone />
</PermissionGuard>
PropTypeDescription
canAccessbooleanWhether the current user has access
fallbackReactNodeOptional custom fallback. Defaults to a "View only" badge
childrenReactNodeContent to render when canAccess is true

ErrorBoundary

Catches unhandled errors in the component tree and renders a fallback UI.

import { ErrorBoundary } from 'noboil/convex/components'

<ErrorBoundary>
  <BlogEditor />
</ErrorBoundary>

<ErrorBoundary
  fallback={({ error, reset }) => (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )}
>
  <BlogEditor />
</ErrorBoundary>
PropTypeDescription
fallback({ error, reset }) => ReactNodeOptional render prop for custom error UI
classNamestringClass for the default error container
childrenReactNodeContent to render normally

Misc Components

OrgAvatar

Org avatar with an initials fallback when no image is available.

import { OrgAvatar } from 'noboil/convex/components'

<OrgAvatar name="Acme Corp" imageUrl={org.logoUrl} size="md" />
PropTypeDescription
namestringOrg name (used for initials fallback)
imageUrlstring | null | undefinedOptional image URL
size'sm' | 'md' | 'lg'Avatar size. Defaults to 'md'
classNamestringOptional class override

RoleBadge

Role badge with automatic variant based on role value.

import { RoleBadge } from 'noboil/convex/components'

<RoleBadge role={member.role} />
<RoleBadge role="admin" className="ml-2" />
PropTypeDescription
rolestringRole string. Variant is derived automatically
classNamestringOptional class override

OfflineIndicator

Fixed bottom-left indicator that appears when the user loses network connectivity. No props.

import { OfflineIndicator } from 'noboil/convex/components'

<OfflineIndicator />

Add it once at the root layout. It renders nothing when the user is online.


Headless Hooks

Every component's logic is also exported as a standalone hook from /react. Use these when you want to bring your own design system.

useStepper

Multi-step wizard state. Returned by defineSteps(). Use the bound version rather than importing directly.

const { StepForm, useStepper } = defineSteps(...)

const stepper = useStepper({
  onSubmit: async d => { ... },
  onSuccess: () => router.push('/done')
})

const { step, next, prev, isFirst, isLast, setStep } = stepper
ReturnTypeDescription
stepstringCurrent step id
setStep(id: string) => voidJump to a step
next() => voidAdvance to next step
prev() => voidGo back one step
isFirstbooleanWhether on the first step
isLastbooleanWhether on the last step

useList

Paginated data fetching with cursor-based or offset-based pagination. See Data fetching for the full options reference.

import { useList } from 'noboil/convex/react'

const { items, loadMore, isLoading, hasMore } = useList(api.blog.list, {
  where: { published: true }
})

useLog

Append-only log subscription. Pairs with log factory. Returns paginated rows + append / appendBulk / purge / restore / update mutations.

import { useLog } from 'noboil/convex/react'

const log = useLog(api.vote, { parent: pollId })

log.data           // Row[]
log.hasMore
await log.loadMore(20)
await log.append({ payload: { optionIdx: 0, voter: 'me' } })
await log.appendBulk([{ optionIdx: 0, voter: 'a' }])
await log.update(rowId, { optionIdx: 1 })
await log.rm(rowId)
await log.purge()       // soft-delete all rows for this parent (when softDelete: true)
await log.restore()     // bring them back

useKv

Single-key value subscription. Pairs with kv factory. Returns data + update / remove / restore.

import { useKv } from 'noboil/convex/react'

const banner = useKv(api.siteConfig, 'banner')

banner.data           // Row | null | undefined
await banner.update({ active: true, message: 'hi' })
await banner.update(payload, { expectedUpdatedAt })   // conflict-checked
await banner.remove()
await banner.restore()

useQuota

Sliding-window rate limit subscription. Pairs with quota factory. Returns reactive state + consume / record.

import { useQuota } from 'noboil/convex/react'

const quota = useQuota(api.pollVoteQuota, pollId)

quota.state              // { allowed, remaining, retryAfter? } (reactive via `check`)
const result = await quota.consume()      // atomic check + record
const after = await quota.record()        // record only

useInfiniteList

Infinite scroll with automatic intersection observer. Loads the next page when the sentinel element enters the viewport.

import { useInfiniteList } from 'noboil/convex/react'

const { data, sentinelRef, isLoading } = useInfiniteList(api.blog.list, { pageSize: 20 })

return (
  <ul>
    {data.map(i => <li key={i._id}>{i.title}</li>)}
    <li ref={sentinelRef} />
    {isLoading && <li>Loading...</li>}
  </ul>
)

useBulkSelection

Multi-select state for lists. Tracks selected IDs and provides toggle helpers.

import { useBulkSelection } from 'noboil/convex/react'

const { selectedIds, toggleOne, toggleAll, clear, isSelected } = useBulkSelection(items)

<input type="checkbox" checked={isSelected(item._id)} onChange={() => toggleOne(item._id)} />
<input type="checkbox" checked={selectedIds.size === items.length} onChange={() => toggleAll()} />
<button onClick={clear}>Clear</button>

useBulkMutate

Batch mutation execution with progress tracking. Runs mutations for all selected items and reports progress.

import { useBulkMutate } from 'noboil/convex/react'

const { execute, progress, isPending } = useBulkMutate({
  mutation: api.blog.rm,
  onSuccess: () => { clear(); toast.success('Deleted') },
  onError: e => toast.error(e.message)
})

<button
  disabled={isPending || selectedIds.size === 0}
  onClick={() => execute([...selectedIds].map(id => ({ id })))}
>
  {isPending ? `Deleting ${progress.done}/${progress.total}...` : 'Delete selected'}
</button>

useSearch

Debounced search with configurable delay. Returns the debounced value to pass to a query.

import { useSearch } from 'noboil/convex/react'

const { query, setQuery, debouncedQuery } = useSearch({ debounceMs: 300 })

const results = useQuery(api.blog.search, { q: debouncedQuery })

<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />

useSoftDelete

Soft delete with undo toast and automatic restore. Marks a record as deleted and shows a toast with an undo action. If the user doesn't undo within the timeout, the record is permanently deleted.

import { useSoftDelete } from 'noboil/convex/react'

const { softDelete, isDeleting } = useSoftDelete({
  deleteMutation: api.blog.rm,
  restoreMutation: api.blog.restore,
  undoMs: 5000,
  onDeleted: () => router.push('/blog')
})

<button disabled={isDeleting} onClick={() => softDelete({ id: post._id })}>
  Delete
</button>

On this page