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
}
})| Param | Type | Description |
|---|---|---|
schema | ZodObject | Zod 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')
})| Param | Type | Description |
|---|---|---|
mutation | MutationReference | Mutation to call on submit |
schema | ZodObject | Zod schema for field types and validation |
values | object | Initial field values (use pickValues to extract from a doc) |
transform | (data) => object | Transform validated data before calling the mutation |
onSuccess | () => void | Called after successful mutation |
onError | (error) => void | false | Called on error. Pass false to suppress default toast |
toast | string | { 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.
| Component | Zod Type | Renders | Key Props |
|---|---|---|---|
Text | string() | Input or textarea | name, label, placeholder, multiline, asyncValidate, asyncDebounceMs, className |
Num | number() | Number input | name, label, min, max, step, className |
Choose | enum() | Select dropdown | name, label, className |
Combobox | string() | Searchable dropdown | name, label, options, className |
MultiSelect | array(enum()) | Multi-select | name, label, className |
Arr | array(string()) | Tag input | name, label, tagClassName, containerClassName, inputClassName |
File | file() | File upload | name, label, accept, className |
Files | files() | Multi-file upload | name, label, accept, maxFiles, className |
Datepick | date() | Date picker | name, label, className |
Timepick | time string | Time picker | name, label, className |
Colorpick | string() | Color picker | name, label, className |
Rating | number() | Star rating | name, label, max, className |
Slider | number() | Range slider | name, label, min, max, step, className |
Toggle | boolean() | Checkbox or switch | name, label, className |
Submit | — | Submit button | children, className |
Err | — | Error display | name |
<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} />| Prop | Type | Description |
|---|---|---|
lastSaved | Date | null | Timestamp of last successful save |
className | string | Optional 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)}
/>| Prop | Type | Description |
|---|---|---|
open | boolean | Controls dialog visibility |
onOverwrite | () => void | Force the current changes through |
onReload | () => void | Fetch the latest version |
onCancel | () => void | Discard 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 key | Targets |
|---|---|
nav | The <nav> wrapping all steps |
step | Each step container |
button | The step number button |
label | The step label text |
separator | The 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"
/>| Prop | Type | Description |
|---|---|---|
editorsList | Editor[] | Current editors |
members | Member[] | All org members (for the add dropdown) |
onAdd | (userId: string) => void | Called when a member is added as editor |
onRemove | (userId: string) => void | Called when an editor is removed |
contentClassName | string | Class for the editors list container |
emptyClassName | string | Class for the empty state message |
headerClassName | string | Class for the section header |
itemClassName | string | Class for each editor row |
triggerClassName | string | Class 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>| Prop | Type | Description |
|---|---|---|
canAccess | boolean | Whether the current user has access |
fallback | ReactNode | Optional custom fallback. Defaults to a "View only" badge |
children | ReactNode | Content 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>| Prop | Type | Description |
|---|---|---|
fallback | ({ error, reset }) => ReactNode | Optional render prop for custom error UI |
className | string | Class for the default error container |
children | ReactNode | Content 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" />| Prop | Type | Description |
|---|---|---|
name | string | Org name (used for initials fallback) |
imageUrl | string | null | undefined | Optional image URL |
size | 'sm' | 'md' | 'lg' | Avatar size. Defaults to 'md' |
className | string | Optional 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" />| Prop | Type | Description |
|---|---|---|
role | string | Role string. Variant is derived automatically |
className | string | Optional 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| Return | Type | Description |
|---|---|---|
step | string | Current step id |
setStep | (id: string) => void | Jump to a step |
next | () => void | Advance to next step |
prev | () => void | Go back one step |
isFirst | boolean | Whether on the first step |
isLast | boolean | Whether 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 backuseKv
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 onlyuseInfiniteList
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>