Skip to main content

Source: ocean/docs/patterns/forms.md | ✏️ Edit on GitHub

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

  1. Use Valibot schemas for consistent validation
  2. Integrate with useMutationWithToast for API calls
  3. Show loading states during submission
  4. Display field errors immediately on blur
  5. Disable submit when form is invalid or submitting
  6. Use semantic HTML (labels, fieldsets, etc.)
  7. Handle keyboard navigation properly
  8. 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>
)
}