Form Patterns
Ocean uses TanStack Form with Valibot validation for type-safe, performant forms with excellent developer experience.
Basic Form Setup
Simple Form Example
import { useForm } from '@tanstack/react-form'
import { loginSchema } from '@/schemas/auth'
import { valibotValidator } from '@tanstack/valibot-form-adapter'
function LoginForm() {
const form = useForm({
defaultValues: {
email: '',
password: ''
},
onSubmit: async ({ value }) => {
await login(value)
},
validators: {
onChange: loginSchema // Valibot schema validation
}
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field name="email">
{(field) => (
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<p className="text-sm text-destructive mt-1">
{field.state.meta.errors.join(', ')}
</p>
)}
</div>
)}
</form.Field>
<Button
type="submit"
disabled={form.state.isSubmitting}
>
{form.state.isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</form>
)
}
Valibot Schemas
Pre-built Schemas
// src/schemas/auth.ts
import { email, minLength, object, pipe, string } from 'valibot'
export const loginSchema = object({
email: pipe(string('Email is required'), email('Invalid email address')),
password: pipe(
string('Password is required'),
minLength(8, 'Password must be at least 8 characters')
),
})
export const signupSchema = object({
email: pipe(string('Email is required'), email('Invalid email address')),
firstName: pipe(string('First name is required'), minLength(1, 'First name is required')),
lastName: pipe(string('Last name is required'), minLength(1, 'Last name is required')),
organizationName: pipe(
string('Organization name is required'),
minLength(2, 'Organization name must be at least 2 characters')
),
})
Custom Validation
import { custom, pipe, string } from 'valibot'
const usernameSchema = pipe(
string('Username is required'),
minLength(3, 'Username must be at least 3 characters'),
custom(async (value) => {
const exists = await checkUsernameExists(value)
return !exists
}, 'Username already taken')
)
Form with Mutations
Integration with useMutationWithToast
import { useMutationWithToast } from '@/hooks/use-mutation-with-toast'
function ProfileForm() {
const updateProfile = useMutationWithToast({
mutationFn: async (data: ProfileUpdate) => {
const response = await apiClient.updateProfile(data)
return response
},
successMessage: 'Profile updated successfully',
invalidateQueries: [['profile']]
})
const form = useForm({
defaultValues: {
firstName: '',
lastName: '',
bio: ''
},
onSubmit: async ({ value }) => {
await updateProfile.mutateAsync(value)
},
validators: {
onChange: profileUpdateSchema
}
})
return (
<form onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}>
{/* Form fields */}
<Button
type="submit"
disabled={updateProfile.isPending || !form.state.canSubmit}
>
{updateProfile.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</form>
)
}
Complex Form Patterns
Multi-Step Form
function MultiStepForm() {
const [step, setStep] = useState(1)
const form = useForm({
defaultValues: {
// Step 1
email: '',
firstName: '',
lastName: '',
// Step 2
organizationName: '',
organizationType: '',
// Step 3
planId: '',
paymentMethod: ''
},
onSubmit: async ({ value }) => {
await createAccount(value)
}
})
const nextStep = async () => {
// Validate current step fields
const stepFields = step === 1
? ['email', 'firstName', 'lastName']
: ['organizationName', 'organizationType']
await form.validateField(...stepFields)
if (form.state.isFieldValid(...stepFields)) {
setStep(step + 1)
}
}
return (
<form>
{step === 1 && <PersonalInfoStep form={form} />}
{step === 2 && <OrganizationStep form={form} />}
{step === 3 && <BillingStep form={form} />}
<div className="flex justify-between">
{step > 1 && (
<Button onClick={() => setStep(step - 1)}>
Previous
</Button>
)}
{step < 3 ? (
<Button onClick={nextStep}>Next</Button>
) : (
<Button type="submit">Complete Setup</Button>
)}
</div>
</form>
)
}
Dynamic Form Fields
function DynamicForm() {
const form = useForm({
defaultValues: {
teamMembers: [{ email: '', role: 'member' }]
},
onSubmit: async ({ value }) => {
await inviteTeamMembers(value.teamMembers)
}
})
const addMember = () => {
form.setFieldValue(
'teamMembers',
[...form.state.values.teamMembers, { email: '', role: 'member' }]
)
}
const removeMember = (index: number) => {
form.setFieldValue(
'teamMembers',
form.state.values.teamMembers.filter((_, i) => i !== index)
)
}
return (
<form>
<form.Field name="teamMembers">
{(field) => (
<div className="space-y-4">
{field.state.value.map((member, index) => (
<div key={index} className="flex gap-4">
<Input
placeholder="email@example.com"
value={member.email}
onChange={(e) => {
const updated = [...field.state.value]
updated[index].email = e.target.value
field.handleChange(updated)
}}
/>
<Select
value={member.role}
onValueChange={(role) => {
const updated = [...field.state.value]
updated[index].role = role
field.handleChange(updated)
}}
>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</Select>
<Button
type="button"
variant="ghost"
onClick={() => removeMember(index)}
>
Remove
</Button>
</div>
))}
</div>
)}
</form.Field>
<Button type="button" onClick={addMember}>
Add Team Member
</Button>
</form>
)
}
File Upload Forms
With Progress Tracking
function FileUploadForm() {
const [uploadProgress, setUploadProgress] = useState(0)
const uploadMutation = useMutationWithToast({
mutationFn: async (file: File) => {
const formData = new FormData()
formData.append('file', file)
return axios.post('/api/upload', formData, {
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total!
)
setUploadProgress(progress)
}
})
},
successMessage: 'File uploaded successfully'
})
const form = useForm({
defaultValues: {
file: null as File | null,
description: ''
},
onSubmit: async ({ value }) => {
if (value.file) {
await uploadMutation.mutateAsync(value.file)
}
}
})
return (
<form>
<form.Field name="file">
{(field) => (
<div>
<Label>Upload File</Label>
<Input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => {
const file = e.target.files?.[0]
field.handleChange(file || null)
}}
/>
{uploadMutation.isPending && (
<Progress value={uploadProgress} className="mt-2" />
)}
</div>
)}
</form.Field>
</form>
)
}
Form State Management
Computed Values
function PricingForm() {
const form = useForm({
defaultValues: {
quantity: 1,
pricePerUnit: 10,
discount: 0
}
})
// Computed total
const total = form.useStore((state) => {
const subtotal = state.values.quantity * state.values.pricePerUnit
const discountAmount = subtotal * (state.values.discount / 100)
return subtotal - discountAmount
})
return (
<form>
{/* Form fields */}
<div className="mt-4 text-lg font-semibold">
Total: ${total.toFixed(2)}
</div>
</form>
)
}
Form Reset
function ResettableForm() {
const form = useForm({
defaultValues: { name: '', email: '' }
})
const handleReset = () => {
form.reset()
// Or reset to specific values
form.reset({ name: 'Default Name', email: '' })
}
return (
<form>
{/* Form fields */}
<div className="flex gap-4">
<Button type="submit">Submit</Button>
<Button type="button" variant="outline" onClick={handleReset}>
Reset
</Button>
</div>
</form>
)
}
Error Handling
Field-Level Errors
function FormWithErrors() {
const form = useForm({
defaultValues: { email: '' },
onSubmit: async ({ value }) => {
try {
await submitForm(value)
} catch (error) {
if (error instanceof ApiError) {
// Set field-specific errors
const fieldErrors = error.getFieldErrors()
Object.entries(fieldErrors).forEach(([field, message]) => {
form.setFieldError(field, message)
})
}
}
}
})
return (
<form>
<form.Field name="email">
{(field) => (
<FormField
label="Email"
error={field.state.meta.errors?.[0]}
description="We'll never share your email"
>
<Input {...field.getInputProps()} />
</FormField>
)}
</form.Field>
</form>
)
}
Best Practices
- Use Valibot schemas for consistent validation
- Integrate with useMutationWithToast for API calls
- Show loading states during submission
- Display field errors immediately on blur
- Disable submit when form is invalid or submitting
- Use semantic HTML (labels, fieldsets, etc.)
- Handle keyboard navigation properly
- Provide clear error messages
Reusable Form Components
FormField Wrapper
function FormField({
label,
error,
description,
required,
children
}: FormFieldProps) {
return (
<div className="space-y-2">
<Label>
{label} {required && <span className="text-destructive">*</span>}
</Label>
{children}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}
Form Actions
function FormActions({
form,
submitLabel = 'Submit',
showReset = false
}) {
return (
<div className="flex justify-end gap-4 mt-6">
{showReset && (
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
)}
<Button
type="submit"
disabled={!form.state.canSubmit || form.state.isSubmitting}
>
{form.state.isSubmitting ? 'Submitting...' : submitLabel}
</Button>
</div>
)
}