Forms
Typesafe form components, validation, file uploads, and error handling.
This guide walks through building a blog post editor, starting simple and adding features incrementally.
A basic create form
You have a blog schema in s.ts and a registered table via noboil(). Build a form to create posts:
import { api } from '@/backend/lazy'
import { Form, useFormMutation } from 'noboil/convex/components'
import { s } from '@/backend/s'
const CreatePost = () => {
const form = useFormMutation({
mutation: api.blog.create,
schema: s.blog
})
return (
<Form
form={form}
render={({ Text, Choose, Toggle, 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>
</>
)}
/>
)
}What's happening:
useFormMutationtakes your Zod schema and generates typed field propsname='title'is checked at compile time —name='titl'is a type errorChooseauto-generates options from the Zod enumToggleknowspublishedis a boolean field — using<Text name='published' />would be a type error- Zod validation runs on submit —
title.min(1)enforces non-empty
Add file upload
Your schema has coverImage: file().nullable().optional(). The <File> field handles upload automatically — Convex routes through native storage, SpacetimeDB stores files inline as byte arrays. The component API is the same.
<Form
form={form}
render={({ Text, Choose, File, Submit }) => (
<>
<Text name="title" label="Title" />
<Text name="content" label="Content" multiline />
<Choose name="category" label="Category" />
<File name="coverImage" label="Cover Image" accept="image/*" />
<Submit>Create</Submit>
</>
)}
/><File name='coverImage' />compiles becausecoverImageis afile()field<File name='title' />is a compile error —titleis a string, not a file- Upload happens on file selection, form submission sends the storage ID
- On delete, the uploaded file is auto-cleaned (Convex storage) or marked for cleanup (SpacetimeDB)
For multi-file uploads, use <Files> against a files() field.
Edit an existing record
useFormMutation pre-fills values from an existing record via pickValues:
import { useFormMutation } from 'noboil/convex/components'
import { pickValues } from 'noboil/convex/zod'
const EditPost = ({ post }: { post: BlogRow }) => {
const form = useFormMutation({
mutation: api.blog.update,
schema: s.blog,
values: pickValues(s.blog, post),
transform: d => ({ ...d, id: post._id, expectedUpdatedAt: post.updatedAt }),
toast: 'Saved'
})
return (
<Form
form={form}
render={({ Text, Choose, File, Submit }) => (
<>
<Text name="title" label="Title" />
<Text name="content" label="Content" multiline />
<Choose name="category" label="Category" />
<File name="coverImage" label="Cover Image" accept="image/*" />
<Submit>Save</Submit>
</>
)}
/>
)
}pickValuesextracts schema-matching fields from the doc (ignores_id,_creationTime,userId,updatedAt)- Empty optional strings auto-coerce to
undefined
Conflict detection
Optimistic concurrency control via expectedUpdatedAt. When the server detects a stale value, the built-in ConflictDialog appears with three options:
- Cancel — discard your changes
- Reload — fetch the latest version
- Overwrite — force your changes through
No extra UI code needed — the dialog is built into Form. Pass expectedUpdatedAt: post.updatedAt in your update call (see the edit example above) and you're done.
Auto-save
For a document editor experience, enable auto-save with debounce:
const form = useFormMutation({
mutation: api.blog.update,
schema: s.blog,
values: pickValues(s.blog, post),
transform: d => ({ ...d, id: post._id }),
autoSave: { enabled: true, debounceMs: 1000 }
})
import { AutoSaveIndicator } from 'noboil/convex/components'
;<AutoSaveIndicator lastSaved={form.lastSaved} />This shows "Saved Xs ago" that updates in real-time.
Async validation
Check if a slug is already taken while the user types:
export const isSlugAvailable = uniqueCheck(s.wiki, 'wiki', 'slug')
<Text name='slug' asyncValidate={async v => {
const ok = await isSlugAvailable({ value: v, exclude: id })
return ok ? undefined : 'Slug already taken'
}} asyncDebounceMs={500} />uniqueCheck is a Convex-specific helper that generates a server-side query backed by an index.
<Text name='slug' asyncValidate={async v => {
const posts = [...conn.db.post.iter()]
const taken = posts.some(p => p.slug === v && p.id !== currentId)
return taken ? 'Slug already taken' : undefined
}} asyncDebounceMs={500} />SpacetimeDB queries the in-memory subscription directly — no server roundtrip needed.
The asyncValidate runs 500ms after the user stops typing and shows inline feedback.
Multi-step wizard
For complex forms like onboarding, use defineSteps to split into typed steps.
import { defineSteps } from 'noboil/convex/components'
const { StepForm, useStepper } = defineSteps(
{ id: 'profile', label: 'Profile', schema: profileStep },
{ id: 'org', label: 'Organization', schema: orgStep },
{ id: 'preferences', label: 'Preferences', schema: preferencesStep }
)
const stepper = useStepper({
onSubmit: async d => {
await upsertProfile({ ...d.profile, ...d.preferences })
await createOrg({ name: d.org.name, slug: d.org.slug })
},
onSuccess: () => toast.success('Done!')
})
<StepForm stepper={stepper} submitLabel='Complete'>
<StepForm.Step id='profile' render={({ Text, File }) => (
<>
<Text name='displayName' label='Name' />
<File name='avatar' label='Avatar' accept='image/*' />
</>
)} />
<StepForm.Step id='org' render={({ Text }) => (
<>
<Text name='name' label='Org Name' />
<Text name='slug' label='URL Slug' />
</>
)} />
<StepForm.Step id='preferences' render={({ Choose }) => (
<Choose name='theme' label='Theme' />
)} />
</StepForm>Customize step indicator styling without forking components — pass stepIndicatorClassNames. Each step has its own Zod schema and independent validation. name='displayName' compiles on the profile step but errors on the org step.
EditorsSection customization
EditorsSection from either lib accepts the same style hooks:
<EditorsSection
contentClassName="rounded-md border"
emptyClassName="italic"
headerClassName="pb-2"
itemClassName="px-2"
triggerClassName="w-48"
editorsList={editors}
members={members}
onAdd={onAdd}
onRemove={onRemove}
/>Optimistic deletes
For instant-feeling delete buttons:
import { useOptimisticMutation } from 'noboil/convex/react'
const { execute, isPending } = useOptimisticMutation({
mutation: api.blog.rm,
onOptimistic: () => onOptimisticRemove?.(),
onRollback: () => toast.error('Failed to delete'),
onSuccess: () => toast.success('Deleted')
})The item disappears immediately. If the server rejects, it reappears with an error toast.
Available field components
See the Components reference for the full 16-field table.
Error handling
Validation errors — Zod validation runs on submit. Field-level errors appear automatically:
const form = useFormMutation({
schema: s.blog,
// Convex: mutation: api.blog.create
// SpacetimeDB: mutate: d => createBlog(d)
})When title.min(1) fails, the <Text name='title' /> field shows "Required" inline — no manual error wiring needed.
Mutation errors — useFormMutation shows error toasts by default. Override with onError:
const form = useFormMutation({
schema: s.blog,
mutation: api.blog.create,
onError: e => toast.error(`Failed: ${e.message}`)
})
// Or suppress the default toast entirely:
const silent = useFormMutation({
schema: s.blog,
mutation: api.blog.create,
onError: false
})Toast shorthand — pass a string for automatic success messaging:
const form = useFormMutation({
schema: s.blog,
mutation: api.blog.create,
toast: 'Created'
})Rate limit errors — when a mutation returns RATE_LIMITED, the default error toast shows "Too many requests". For custom UX, use handleError:
import { handleError } from 'noboil/convex/server'
const form = useFormMutation({
schema: s.blog,
mutation: api.blog.create,
onError: e =>
handleError(e, {
RATE_LIMITED: ({ retryAfter }) => toast.error(`Slow down — try again in ${retryAfter}ms`),
CONFLICT: () => toast.error('Someone else edited this'),
default: () => toast.error('Something went wrong')
})
})Error boundary — wrap pages with ErrorBoundary to catch unhandled errors:
import { ErrorBoundary } from 'noboil/convex/components'
<ErrorBoundary>
<BlogEditor />
</ErrorBoundary>