Create Form Skill
Use this skill when creating forms with validation that coordinate between frontend and Convex backend.
Schema Definition (src/schemas/)
typescript
// src/schemas/signature.ts
import * as z from "zod"
// Part 1: Config object with constraints and defaults (camelCase)
export const createSignature = {
min: { name: 1, title: 1, company: 1, xUsername: 1 },
max: {
name: 80,
title: 80,
company: 80,
because: 160,
commitment: 160,
xUsername: 24,
},
defaultValues:
process.env.NODE_ENV === "development"
? { name: "Dev User", title: "Engineer", company: "Acme", xUsername: "", because: "", commitment: "" }
: { name: "", title: "", company: "", xUsername: "", because: "", commitment: "" },
}
// Part 2: Zod schema with validation (PascalCase)
export const CreateSignature = z.object({
name: z.string().trim()
.min(createSignature.min.name, "Name is required")
.max(createSignature.max.name),
title: z.string().trim()
.min(createSignature.min.title, "Title is required")
.max(createSignature.max.title),
company: z.string().trim()
.min(createSignature.min.company, "Company is required")
.max(createSignature.max.company),
xUsername: z.string().trim()
.transform((v) => (v.startsWith("@") ? v.slice(1) : v))
.pipe(
z.string()
.min(createSignature.min.xUsername, "X username is required")
.max(createSignature.max.xUsername)
.regex(/^[a-zA-Z0-9_]+$/, "Invalid X username format")
),
because: z.string().trim()
.max(createSignature.max.because)
.transform((v) => (v.endsWith(".") ? v.slice(0, -1) : v)),
commitment: z.string().trim()
.max(createSignature.max.commitment)
.transform((v) => (v.endsWith(".") ? v.slice(0, -1) : v)),
referredBy: z.string().optional(),
})
// Part 3: TypeScript type (same name as schema)
export type CreateSignature = z.infer<typeof CreateSignature>
Client Component Setup
typescript
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery } from "convex/react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useEffectEvent, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { api } from "@/convex/_generated/api"
import useAsyncFn from "@/hooks/use-async-fn"
import { CreateSignature, createSignature } from "@/schemas/signature"
export const SignatureForm: React.FC<{ className?: string }> = ({ className }) => {
// Skip state for optional sections
const [skippedBecause, setSkippedBecause] = useState(false)
const [skippedCommitment, setSkippedCommitment] = useState(false)
// Mutation with useAsyncFn
const create = useAsyncFn(useMutation(api.signatures.mutate.create))
const signatureId = create.data?.data?.signatureId
// Form setup with referral tracking
const form = useForm({
resolver: zodResolver(CreateSignature),
defaultValues: {
...createSignature.defaultValues,
},
mode: "onBlur",
})
// Section visibility logic
const { name, title, company, because, commitment } = form.watch()
const showWhy = name.length > 0 && title.length > 0 && company.length > 0
const showCommitment = showWhy && (because.length > 0 || skippedBecause)
const showXUsername = showCommitment && (commitment.length > 0 || skippedCommitment)
// Submit handler
const handleSign = useEffectEvent(async (formData: CreateSignature) => {
await create.execute(formData)
})
// Clear referral on success
useEffect(() => {
if (signatureId) clearReferredBy()
}, [signatureId])
return (
<form onSubmit={form.handleSubmit(handleSign)}>
{/* Hidden referral input */}
<input type="hidden" {...form.register("referredBy")} />
{/* Form fields... */}
</form>
)
}
Animation Pattern
Define a reusable animation config for progressive disclosure:
typescript
const revealAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
transition: { duration: 0.4, ease: "easeOut" },
} as const
// Usage with AnimatePresence
<AnimatePresence>
{showSection && (
<motion.div {...revealAnimation}>
{/* Section content */}
</motion.div>
)}
</AnimatePresence>
Skip Button Pattern
For optional sections that users can skip:
typescript
type SkipButtonProps = {
setState: React.Dispatch<React.SetStateAction<boolean>>
}
const SkipButton: React.FC<SkipButtonProps> = ({ setState }) => (
<Button onClick={() => setState(true)} size="sm" type="button" variant="outline">
Skip
</Button>
)
// Usage
{!fieldValue && <SkipButton setState={setSkippedSection} />}
Inline Form Pattern (Sentence-Style)
For forms embedded in prose using InlineField and LetterInput:
typescript
import { InlineField } from "@/components/ui/inline-field"
import { LetterInput } from "@/components/ui/letter-input"
import { HStack } from "@/components/layout/stack"
<HStack className="gap-y-0.5" items="baseline" wrap>
I,
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<InlineField>
<LetterInput
{...field}
aria-invalid={fieldState.invalid}
autoComplete="name"
disabled={formState.isSubmitted}
maxLength={createSignature.max.name}
placeholder="Full Name"
/>
</InlineField>
)}
/>
,
<Controller
control={form.control}
name="title"
render={({ field, fieldState }) => (
<InlineField>
<LetterInput
{...field}
aria-invalid={fieldState.invalid}
maxLength={createSignature.max.title}
placeholder="Title"
/>
</InlineField>
)}
/>
at
<Controller
control={form.control}
name="company"
render={({ field, fieldState }) => (
<InlineField>
<LetterInput
{...field}
aria-invalid={fieldState.invalid}
autoComplete="organization"
maxLength={createSignature.max.company}
placeholder="Company"
/>
</InlineField>
)}
/>
</HStack>
X Username Input Pattern
Using InputGroup for prefixed/suffixed inputs:
typescript
import { FaXTwitter } from "react-icons/fa6"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group"
<Controller
control={form.control}
name="xUsername"
render={({ field, fieldState }) => (
<InputGroup className="w-fit">
<InputGroupAddon align="inline-start">
<InputGroupText>@</InputGroupText>
</InputGroupAddon>
<InputGroupInput
{...field}
aria-invalid={fieldState.invalid}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
maxLength={createSignature.max.xUsername}
placeholder="username"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>
<FaXTwitter />
</InputGroupText>
</InputGroupAddon>
</InputGroup>
)}
/>
Block Field Pattern
For traditional form fields using the Field component system:
typescript
import { Field, FieldLabel, FieldContent, FieldError, FieldDescription } from "@/components/ui/field"
<Controller
control={form.control}
name="because"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Why I'm signing (optional)</FieldLabel>
<FieldContent>
<Textarea
{...field}
id={field.name}
rows={3}
maxLength={createSignature.max.because}
aria-invalid={fieldState.invalid}
placeholder="My parents left Iran in 1979..."
/>
</FieldContent>
<FieldDescription className="text-right">
{field.value?.length ?? 0} / {createSignature.max.because} characters
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
Success Section Pattern
After successful form submission:
typescript
import { LuCheck, LuCopy, LuArrowRight } from "react-icons/lu"
import { NumberTicker } from "@/components/ui/number-ticker"
import { CopyButton } from "@/components/ui/copy-button"
import { SocialShareButtons } from "@/components/social-share-buttons"
import { Separator } from "@/components/ui/separator"
import { url } from "@/lib/utils"
type SuccessSectionProps = { signatureId: Id<"signatures"> }
const SuccessSection: React.FC<SuccessSectionProps> = ({ signatureId }) => {
const totalCount = useQuery(api.signatures.query.count, signatureId ? {} : "skip")
const shareURL = signatureId ? url(`/sig/${signatureId}`) : ""
return (
<VStack className="gap-8 items-center text-center">
{/* Success icon */}
<div className="flex size-16 items-center justify-center rounded-full bg-green-500/10">
<LuCheck className="size-8 text-green-500" />
</div>
{/* Confirmation message */}
<VStack className="gap-2 items-center">
<h2 className="text-2xl font-semibold sm:text-3xl">You've signed the letter.</h2>
<p className="text-muted-foreground">
Join <NumberTicker className="font-medium tabular-nums" value={totalCount ?? 0} />{" "}
founders ready for a free Iran.
</p>
</VStack>
{/* Share URL */}
<VStack className="gap-3">
<span className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
Share your pledge
</span>
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3">
<code className="flex-1 truncate font-mono text-sm">
{shareURL.replace(/^https?:\/\//, "")}
</code>
<CopyButton content={shareURL} leftIcon={LuCopy} size="sm" variant="ghost">
Copy Link
</CopyButton>
</div>
</VStack>
<SocialShareButtons className="w-full" url={shareURL} />
<Separator className="max-w-md opacity-30" />
<Button asChild variant="link">
<Link href="/">
See all commitments
<LuArrowRight className="size-4" />
</Link>
</Button>
</VStack>
)
}
// Usage in form
<AnimatePresence>
{!!signatureId && (
<motion.div {...revealAnimation}>
<Separator className="opacity-30 mb-8" />
<SuccessSection signatureId={signatureId} />
</motion.div>
)}
</AnimatePresence>
Convex Mutation with Validation
typescript
// src/convex/signatures/mutate.ts
import { CreateSignature } from "@/schemas/signature"
import { errorMessage, mutation } from "@/convex/helpers/server"
type Create =
| { signatureId: Id<"signatures">; success: string }
| { signatureId: null; error: string }
export const create = mutation({
args: {
name: v.string(),
title: v.string(),
company: v.string(),
xUsername: v.string(),
because: v.string(),
commitment: v.string(),
referredBy: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Create> => {
// Server-side validation with shared schema
const { data, success, error } = CreateSignature.safeParse(args)
if (!success) {
return { signatureId: null, error: errorMessage(error) }
}
// Check for duplicate xUsername
const existing = await ctx.db
.query("signatures")
.withIndex("by_xUsername", (q) => q.eq("xUsername", data.xUsername))
.first()
if (existing) {
return { signatureId: null, error: "This X username has already signed." }
}
// Create signature
const signatureId = await ctx.db.insert("signatures", {
...data,
referredBy: args.referredBy ?? null,
pinned: false,
upvoteCount: 0,
})
return { signatureId, success: "You've signed the letter!" }
},
})
Data Flow
code
Schema Definition (src/schemas/)
|
Client Form Setup (useForm + zodResolver)
|
Section Visibility (form.watch + boolean conditions)
|
Field Components (Controller + InlineField/LetterInput or Field system)
|
Progressive Disclosure (AnimatePresence + motion + revealAnimation)
|
Skip Buttons (optional sections)
|
Form Submit (useAsyncFn + useEffectEvent)
|
Convex Mutation (re-validates with same Zod schema)
|
Discriminated Union Return ({ success } | { error })
|
Success State (SuccessSection with share URL)
|
Clear Referral (clearReferredBy on success)
Checklist
- • Schema in
src/schemas/with config (camelCase) + Zod (PascalCase) + type - • Config includes
min,max, anddefaultValues - • Dev defaults for faster testing
- • Form setup with
zodResolver(Schema)andmode: "onBlur" - • Hidden input for
referredBy - • Section visibility via
form.watch()destructuring - • Skip state for optional sections (
useState(false)) - •
revealAnimationconst for consistent animations - •
AnimatePresencewrapper for conditional sections - •
InlineField+LetterInputfor sentence-style forms - •
InputGrouppattern for prefixed inputs (X username) - •
useAsyncFnwraps mutation - •
useEffectEventfor stable submit handler - • Clear referral on success with
clearReferredBy() - • Server re-validates with same Zod schema
- • Discriminated union return type
- • Success section with share URL and social buttons