noboil

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:

  • useFormMutation takes your Zod schema and generates typed field props
  • name='title' is checked at compile time — name='titl' is a type error
  • Choose auto-generates options from the Zod enum
  • Toggle knows published is 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 because coverImage is a file() field
  • <File name='title' /> is a compile error — title is 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>
        </>
      )}
    />
  )
}
  • pickValues extracts 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 errorsuseFormMutation 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>

On this page