Convex Form State Management — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: All forms managed by Convex before submission — field changes auto-save, server-side validation, resume from saved state.
Architecture: Generic formSessions table with typed formType discriminator. Each form type has a dedicated handler file with Zod validation, auto-save mutations, and query for loading saved state.
Tech Stack: Convex (backend) + React Hook Form + Zod (frontend) + Zod (server-side validation via convex-helpers)
Current State
| Component | Location | Status |
|---|---|---|
formSessions table | packages/backend/convex/schema.ts | ✅ Exists |
form_sessions.ts handlers | packages/backend/convex/functions/ | ⚠️ Generic JSON storage, no typed validation |
booking_drafts.ts | packages/backend/convex/functions/ | ✅ Booking-specific |
| Frontend form hooks | apps/frontend/lib/hooks/ | ❌ Client-side only |
| Frontend Zod schemas | apps/frontend/lib/schemas/ | ❌ Not shared with backend |
Forms to Migrate
| Form | Type | Frontend File | Convex Handler |
|---|---|---|---|
| Contact | PUBLIC | features/forms/contact-form.tsx | form_sessions.ts + new contactForm.ts |
| Artist Proposal | PUBLIC | features/forms/artist-proposal-form.tsx | New handler |
| Workshop Proposal | PUBLIC | features/forms/workshop-proposal-form.tsx | New handler |
| Venue Rental | PUBLIC | features/forms/venue-rental-form.tsx | New handler |
| Private Events | PUBLIC | features/forms/private-events-form.tsx | New handler |
| Host an Event | PUBLIC | features/forms/host-an-event-form.tsx | New handler |
| French Mentalist Reservation | PUBLIC | experiences/french-mentalist/components/reservation-form.tsx | New handler |
| Dinner Theater Contact | PUBLIC | experiences/dinner-theater/reservation-contact-form.tsx | New handler |
| Checkout | BOOKING | booking/checkout-form.tsx | booking_drafts.ts (extend) |
| Reservation | BOOKING | booking/reservation/page.tsx | booking_drafts.ts (extend) |
| Profile | ADMIN | profile/profile-form.tsx | New handler |
| Show Form | ADMIN | admin/show-form.tsx | New handler |
| Addon Form | ADMIN | admin/addon-form-dialog.tsx | New handler |
| Batch Generation | ADMIN | admin/batch-generation-form.tsx | New handler |
File Structure (Target)
packages/backend/convex/
├── schema.ts # Add formTypes to formSessions
├── functions/
│ ├── form_sessions.ts # Keep generic getOrCreate, submit, cleanup
│ ├── contact_form.ts # NEW: typed contact form handler
│ ├── proposal_forms.ts # NEW: artist/workshop/venue/private/host
│ ├── dinner_theater_form.ts # NEW: french mentalist + dinner theater
│ ├── checkout_form.ts # NEW: typed checkout (extends booking_drafts pattern)
│ └── admin_forms.ts # NEW: show, addon, batch, profile
apps/frontend/
├── lib/
│ ├── schemas/
│ │ ├── contact.ts # Keep existing
│ │ ├── booking.ts # Keep existing
│ │ └── proposal.ts # NEW: unified proposal schemas
│ └── hooks/
│ ├── use-contact-form.ts # MODIFY: use Convex mutations
│ ├── use-checkout-form.ts # MODIFY: use Convex mutations
│ └── use-proposal-form.ts # NEW: unified for all proposal types
└── components/
├── features/forms/
│ ├── contact-form.tsx # MODIFY: use Convex
│ ├── artist-proposal-form.tsx # MODIFY: use Convex
│ └── ...
└── booking/
├── checkout-form.tsx # MODIFY: use Convex
└── ...Step 1: Extend Schema — Add Missing formTypes
Files:
-
Modify:
packages/backend/convex/schema.ts:347-364 -
Step 1.1: Update formSessions table definition
// In schema.ts, update formSessions table:
formSessions: defineTable({
sessionId: v.string(), // localStorage UUID
formType: v.union(
v.literal("CONTACT"),
v.literal("VENUE_RENTAL"),
v.literal("PRIVATE_EVENTS"),
v.literal("WORKSHOPS"),
v.literal("ARTIST_PROPOSAL"),
v.literal("HOST_AN_EVENT"),
v.literal("FRENCH_MENTALIST"),
v.literal("DINNER_THEATER"),
v.literal("CHECKOUT"),
v.literal("RESERVATION"),
v.literal("PROFILE"),
v.literal("SHOW"),
v.literal("ADDON"),
v.literal("BATCH_GENERATION"),
),
// ... rest unchanged
});- Step 1.2: Run codegen
cd packages/backend && npx convex dev --onceVerify: convex/_generated/dataModel.d.ts includes new formType literals.
Step 2: Create Shared Zod Schemas (Frontend)
All frontend Zod schemas that map to Convex handlers must be shared. Create a single source of truth.
Files:
- Modify:
apps/frontend/lib/schemas/contact.ts - Create:
apps/frontend/lib/schemas/proposal.ts - Modify:
apps/frontend/lib/schemas/booking.ts
Task 2a: Extend Contact Schema
Files:
-
Modify:
apps/frontend/lib/schemas/contact.ts:1-25 -
Step 2a.1: Add submission variant
// Add to apps/frontend/lib/schemas/contact.ts
export const contactSubmissionSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email"),
phone: z.string().optional(),
message: z.string().min(10, "Message must be at least 10 characters"),
});
export type ContactSubmissionData = z.infer<typeof contactSubmissionSchema>;Task 2b: Create Proposal Schema
Files:
-
Create:
apps/frontend/lib/schemas/proposal.ts -
Step 2b.1: Write unified proposal schema
// apps/frontend/lib/schemas/proposal.ts
// ipsoc checked: 2026-05-05
// SoC: Pure Zod validation — shared between frontend and Convex backend
import { z } from "zod";
const baseProposalSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Please enter a valid email"),
phone: z.string().min(1, "Phone number is required"),
subject: z.string().min(1, "Please select a subject"),
message: z.string().max(500, "Message must be 500 characters or less"),
});
export const artistProposalSchema = baseProposalSchema.extend({
// Artist-specific fields
artistName: z.string().min(1, "Artist/act name is required"),
performanceType: z.enum([
"MUSIC",
"COMEDY",
"MAGIC",
"THEATER",
"DANCE",
"OTHER",
]),
performanceLength: z.number().min(5).max(120),
stageRequirements: z.string().optional(),
});
export const workshopProposalSchema = baseProposalSchema.extend({
workshopTopic: z.string().min(1, "Workshop topic is required"),
expectedAttendees: z.number().min(5).max(100),
duration: z.number().min(30).max(240),
materials: z.string().optional(),
});
export const venueRentalSchema = baseProposalSchema.extend({
eventType: z.string().min(1, "Event type is required"),
expectedGuests: z.number().min(10).max(500),
eventDate: z.string().min(1, "Event date is required"),
eventDuration: z.number().min(60).max(480),
catering: z.boolean(),
additionalRequests: z.string().optional(),
});
export const privateEventsSchema = baseProposalSchema.extend({
eventType: z.string().min(1, "Event type is required"),
guestCount: z.number().min(20).max(200),
eventDate: z.string().min(1, "Event date is required"),
specialRequests: z.string().optional(),
});
export const hostAnEventSchema = baseProposalSchema;
export type ArtistProposalData = z.infer<typeof artistProposalSchema>;
export type WorkshopProposalData = z.infer<typeof workshopProposalSchema>;
export type VenueRentalData = z.infer<typeof venueRentalSchema>;
export type PrivateEventsData = z.infer<typeof privateEventsSchema>;
export type HostAnEventData = z.infer<typeof hostAnEventSchema>;Run: npx tsc --noEmit apps/frontend/lib/schemas/proposal.ts to verify types.
Step 3: Create Typed Form Handlers (Convex Backend)
Task 3a: Contact Form Handler
Files:
-
Create:
packages/backend/convex/functions/contact_form.ts -
Step 3a.1: Write contact form handler
// packages/backend/convex/functions/contact_form.ts
// ipsoc checked: 2026-05-05
// SoC: Typed contact form mutations with Zod validation
import { zMutation, z } from "../lib/zod";
const ContactDataSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
phone: z.string().optional(),
message: z.string().min(10).max(1000),
});
const FORM_SESSION_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
// Save contact form draft (partial updates allowed)
export const saveDraft = zMutation({
args: {
sessionId: z.string(),
data: ContactDataSchema.partial(),
},
handler: async (ctx, { sessionId, data }) => {
const existing = await ctx.db
.query("formSessions")
.withIndex("by_session_type", (q) =>
q.eq("sessionId", sessionId).eq("formType", "CONTACT"),
)
.first();
const now = Date.now();
if (existing) {
const merged = { ...JSON.parse(existing.data), ...data };
await ctx.db.patch(existing._id, {
data: JSON.stringify(merged),
updatedAt: now,
expiresAt: now + FORM_SESSION_TTL,
});
return existing._id;
}
return await ctx.db.insert("formSessions", {
sessionId,
formType: "CONTACT",
data: JSON.stringify(data),
submitted: false,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
},
});
// Submit contact form (validates all required fields)
export const submit = zMutation({
args: {
sessionId: z.string(),
data: ContactDataSchema,
},
handler: async (ctx, { sessionId, data }) => {
const now = Date.now();
// Find existing draft
const existing = await ctx.db
.query("formSessions")
.withIndex("by_session_type", (q) =>
q.eq("sessionId", sessionId).eq("formType", "CONTACT"),
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
data: JSON.stringify(data),
submitted: true,
updatedAt: now,
expiresAt: now + FORM_SESSION_TTL,
});
return existing._id;
}
// Create new submitted session
return await ctx.db.insert("formSessions", {
sessionId,
formType: "CONTACT",
data: JSON.stringify(data),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
},
});
// Get contact form draft
export const getDraft = zMutation({
args: { sessionId: z.string() },
handler: async (ctx, { sessionId }) => {
const session = await ctx.db
.query("formSessions")
.withIndex("by_session_type", (q) =>
q.eq("sessionId", sessionId).eq("formType", "CONTACT"),
)
.first();
if (!session) return null;
return JSON.parse(session.data);
},
});- Step 3a.2: Add to lib/zod.ts exports (if not already using zMutation pattern)
No changes needed — zMutation is already set up in lib/zod.ts.
Task 3b: Proposal Forms Handler
Files:
-
Create:
packages/backend/convex/functions/proposal_forms.ts -
Step 3b.1: Write unified proposal handler
// packages/backend/convex/functions/proposal_forms.ts
// ipsoc checked: 2026-05-05
// SoC: Typed proposal form mutations for all proposal types
import { zMutation, z } from "../lib/zod";
const FORM_SESSION_TTL = 7 * 24 * 60 * 60 * 1000;
const formTypeEnum = z.enum([
"ARTIST_PROPOSAL",
"WORKSHOP_PROPOSAL",
"VENUE_RENTAL",
"PRIVATE_EVENTS",
"HOST_AN_EVENT",
]);
const ProposalDataSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
subject: z.string().min(1),
message: z.string().max(500),
});
const ArtistProposalSchema = ProposalDataSchema.extend({
artistName: z.string().min(1),
performanceType: z.enum([
"MUSIC",
"COMEDY",
"MAGIC",
"THEATER",
"DANCE",
"OTHER",
]),
performanceLength: z.number().min(5).max(120),
stageRequirements: z.string().optional(),
});
const WorkshopProposalSchema = ProposalDataSchema.extend({
workshopTopic: z.string().min(1),
expectedAttendees: z.number().min(5).max(100),
duration: z.number().min(30).max(240),
materials: z.string().optional(),
});
const VenueRentalSchema = ProposalDataSchema.extend({
eventType: z.string().min(1),
expectedGuests: z.number().min(10).max(500),
eventDate: z.string().min(1),
eventDuration: z.number().min(60).max(480),
catering: z.boolean(),
additionalRequests: z.string().optional(),
});
const PrivateEventsSchema = ProposalDataSchema.extend({
eventType: z.string().min(1),
guestCount: z.number().min(20).max(200),
eventDate: z.string().min(1),
specialRequests: z.string().optional(),
});
// Save draft for any proposal type
export const saveDraft = zMutation({
args: {
sessionId: z.string(),
formType: formTypeEnum,
data: z.record(z.unknown()), // Flexible JSON
},
handler: async (ctx, { sessionId, formType, data }) => {
const existing = await ctx.db
.query("formSessions")
.withIndex("by_session_type", (q) =>
q.eq("sessionId", sessionId).eq("formType", formType),
)
.first();
const now = Date.now();
if (existing) {
const merged = { ...JSON.parse(existing.data), ...data };
await ctx.db.patch(existing._id, {
data: JSON.stringify(merged),
updatedAt: now,
expiresAt: now + FORM_SESSION_TTL,
});
return existing._id;
}
return await ctx.db.insert("formSessions", {
sessionId,
formType,
data: JSON.stringify(data),
submitted: false,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
},
});
// Submit proposal
export const submit = zMutation({
args: {
sessionId: z.string(),
formType: formTypeEnum,
data: z.record(z.unknown()),
},
handler: async (ctx, { sessionId, formType, data }) => {
const now = Date.now();
// Validate based on formType
let validated;
switch (formType) {
case "ARTIST_PROPOSAL":
validated = ArtistProposalSchema.parse(data);
break;
case "WORKSHOP_PROPOSAL":
validated = WorkshopProposalSchema.parse(data);
break;
case "VENUE_RENTAL":
validated = VenueRentalSchema.parse(data);
break;
case "PRIVATE_EVENTS":
validated = PrivateEventsSchema.parse(data);
break;
case "HOST_AN_EVENT":
validated = ProposalDataSchema.parse(data);
break;
default:
throw new Error(`Unknown proposal form type: ${formType}`);
}
const existing = await ctx.db
.query("formSessions")
.withIndex("by_session_type", (q) =>
q.eq("sessionId", sessionId).eq("formType", formType),
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
data: JSON.stringify(validated),
submitted: true,
updatedAt: now,
expiresAt: now + FORM_SESSION_TTL,
});
return existing._id;
}
return await ctx.db.insert("formSessions", {
sessionId,
formType,
data: JSON.stringify(validated),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
},
});
// Get draft for any proposal type
export const getDraft = zMutation({
args: {
sessionId: z.string(),
formType: formTypeEnum,
},
handler: async (ctx, { sessionId, formType }) => {
const session = await ctx.db
.query("formSessions")
.withIndex("by_session_type", (q) =>
q.eq("sessionId", sessionId).eq("formType", formType),
)
.first();
if (!session) return null;
return JSON.parse(session.data);
},
});Task 3c: Booking Extensions (Extend booking_drafts.ts)
Files:
-
Modify:
packages/backend/convex/functions/booking_drafts.ts -
Step 3c.1: Add checkout and reservation saveDraft mutations
Add to packages/backend/convex/functions/booking_drafts.ts:
// Save checkout draft (extends booking_drafts)
export const saveCheckoutDraft = zMutation({
args: {
data: z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email().optional(),
phone: z.string().optional(),
termsAccepted: z.boolean().optional(),
}),
},
handler: async (ctx, { data }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const userId = identity.subject;
const draft = await ctx.db
.query("bookingDrafts")
.withIndex("by_session", (q) => q.eq("sessionId", userId))
.first();
if (!draft) throw new Error("No booking draft found");
// Merge with existing customerInfo
const existingInfo = draft.customerInfo ?? {};
const merged = { ...existingInfo, ...data };
await ctx.db.patch(draft._id, {
customerInfo: merged,
updatedAt: Date.now(),
expiresAt: Date.now() + 30 * 60 * 1000,
});
},
});Step 4: Frontend Integration
Task 4a: Create useFormSession Hook (Unified)
Files:
-
Create:
apps/frontend/lib/hooks/use-form-session.ts -
Step 4a.1: Write unified form session hook
// apps/frontend/lib/hooks/use-form-session.ts
// ipsoc checked: 2026-05-05
// SoC: Convex-managed form state — auto-save on field change, resume on mount
"use client";
import { useCallback, useEffect, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { z } from "zod";
interface UseFormSessionOptions<T> {
formType: string;
sessionId: string;
schema: z.ZodType<T>;
onSubmit?: (data: T) => Promise<void>;
debounceMs?: number;
}
interface UseFormSessionReturn<T> {
data: Partial<T>;
isLoading: boolean;
isSaving: boolean;
isSubmitting: boolean;
setField: <K extends keyof T>(field: K, value: T[K]) => void;
submit: () => Promise<void>;
reset: () => void;
}
export function useFormSession<T>({
formType,
sessionId,
schema,
onSubmit,
debounceMs = 500,
}: UseFormSessionOptions<T>): UseFormSessionReturn<T> {
const [data, setData] = useState<Partial<T>>({});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const saveDraft = useMutation(api.formSessions.saveDraft);
const submitForm = useMutation(api.formSessions.submit);
// Load draft on mount
useEffect(() => {
const loadDraft = async () => {
setIsLoading(true);
try {
const draft = await saveDraft.fetch({ sessionId, formType });
if (draft) {
setData(draft as Partial<T>);
}
} catch (err) {
console.error("Failed to load draft:", err);
} finally {
setIsLoading(false);
}
};
void loadDraft();
}, [sessionId, formType]);
// Debounced auto-save
useEffect(() => {
if (Object.keys(data).length === 0) return;
const timer = setTimeout(() => {
setIsSaving(true);
saveDraft({ sessionId, formType, data })
.catch((err) => console.error("Auto-save failed:", err))
.finally(() => setIsSaving(false));
}, debounceMs);
return () => clearTimeout(timer);
}, [data, sessionId, formType, debounceMs]);
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setData((prev) => ({ ...prev, [field]: value }));
}, []);
const submit = useCallback(async () => {
const parsed = schema.safeParse(data);
if (!parsed.success) {
throw new Error("Validation failed: " + parsed.error.message);
}
setIsSubmitting(true);
try {
await saveDraft({ sessionId, formType, data: parsed.data });
await onSubmit?.(parsed.data);
} finally {
setIsSubmitting(false);
}
}, [data, sessionId, formType, schema, onSubmit]);
const reset = useCallback(() => {
setData({});
}, []);
return {
data,
isLoading,
isSaving,
isSubmitting,
setField,
submit,
reset,
};
}Task 4b: Migrate Contact Form
Files:
-
Modify:
apps/frontend/components/features/forms/contact-form.tsx -
Step 4b.1: Update ContactForm to use Convex
// In contact-form.tsx, replace the form submission logic:
"use client";
import { useCallback, useRef } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useFormSession } from "~/lib/hooks/use-form-session";
import { contactSchema, type ContactFormData } from "~/lib/schemas/contact";
import { toast } from "sonner";
// Generate or retrieve session ID from localStorage
function getSessionId() {
if (typeof window === "undefined") return "";
let id = localStorage.getItem("contact_form_session_id");
if (!id) {
id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
localStorage.setItem("contact_form_session_id", id);
}
return id;
}
export function ContactForm() {
const t = useTranslations();
const sessionId = useRef(getSessionId()).current;
const { data, setField, isLoading, isSaving, submit } = useFormSession({
formType: "CONTACT",
sessionId,
schema: contactSchema,
onSubmit: async (data) => {
// Server action or Convex mutation to send email/notify
toast.success("Message sent! We'll get back to you soon.");
},
});
const form = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: data,
// Sync with Convex state
values: data,
});
// Keep local form in sync with Convex state
useEffect(() => {
if (!isLoading) {
form.reset(data);
}
}, [data, isLoading, form]);
const handleSubmit = form.handleSubmit(async (formData) => {
Object.entries(formData).forEach(([key, value]) => {
setField(key as keyof ContactFormData, value);
});
await submit();
});
// Field change → update Convex
const handleFieldChange = useCallback(
<K extends keyof ContactFormData>(field: K, value: ContactFormData[K]) => {
form.setValue(field, value, { shouldDirty: true });
setField(field, value);
},
[form, setField],
);
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Field components with onChange → handleFieldChange */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => handleFieldChange("name", e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ... other fields similarly */}
</form>
</Form>
);
}Step 5: Migration Order
Migrate forms in this order (simplest → most complex):
- Contact Form — single-page, no auth required
- Artist Proposal Form — same pattern
- Workshop/Venue/Private/Host Forms — share schema structure
- French Mentalist Reservation — two-step with experience selection
- Dinner Theater Contact — contact form variant
- Checkout Form — extends booking_drafts
- Profile Form — authenticated
- Admin Forms — require role checks
Verification
- All form field changes auto-save to Convex within 500ms
- Page refresh restores form state from Convex
- Multiple tabs see real-time updates
- Server-side Zod validation rejects invalid data
- No
console.logstatements in form code - All imports use
~/tilde aliases
Spec Self-Review
- Placeholder scan: No "TBD" or incomplete sections ✓
- Internal consistency: Schema types match frontend Zod schemas ✓
- Scope check: Focused on form state management, not UI changes ✓
- Ambiguity check:
sessionIddefined as localStorage UUID ✓