Forms Architecture
This guide covers the complete forms setup for the cats project, including patterns, validation, and best practices.
Overview
All forms use Formik for state management and Yup for validation. This combination provides:
- Declarative form state management
- Built-in validation with clear error handling
- TypeScript support with auto-generated types
- Consistent patterns across all forms
Core Components
1. Basic Form Structure
import { Formik, Form } from 'formik'
import * as Yup from 'yup'
type FormValues = {
firstName: string
email: string
}
const validationSchema = Yup.object().shape({
firstName: Yup.string().required('First name is required'),
email: Yup.string().email('Invalid email').required('Email is required')
})
function MyForm() {
const initialValues: FormValues = {
firstName: '',
email: ''
}
const handleSubmit = (values: FormValues) => {
console.log('Form submitted:', values)
}
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
<Form>
{/* Form fields go here */}
</Form>
</Formik>
)
}2. SteppedForm Component
For multi-step forms (like onboarding), use the SteppedForm component:
import SteppedForm, { Step } from '@cats/locale/components/SteppedForm/SteppedForm'
const steps: Step[] = [
{
id: 'step-1',
step: <Step1Component />,
title: 'wizard.step1.title',
next: 'wizard.buttons.next'
},
{
id: 'step-2',
step: <Step2Component />,
title: 'wizard.step2.title',
next: 'wizard.buttons.next'
}
]
function WizardForm() {
return (
<SteppedForm
cats={cats}
steps={steps}
onSubmit={handleSubmit}
stepperType="wizard"
/>
)
}3. FormControlCard Component
For custom form controls with card-based UI:
import FormControlCard from '@cats/locale/components/FormControlCard/FormControlCard'
<FormControlCard
value="option1"
selected={formik.values.choice === 'option1'}
testid="choice-option-1"
>
<Typography>Option 1 Label</Typography>
</FormControlCard>Validation System
Core Validation Utilities
The project provides reusable validation functions in packages/core/utilities/validations.ts:
import {
emailValidations,
firstNameValidation,
lastNameValidation,
phoneValidation,
passwordValidation
} from 'core/utilities/validations'
const schema = Yup.object().shape({
firstName: firstNameValidation(hints),
lastName: lastNameValidation(hints),
email: emailValidations(hints),
phone: phoneValidation('en-GB', hints),
password: passwordValidation()
})Custom Validations
For domain-specific validation (like weight), see apps/cats/src/validations/validations.ts:
import { weightValidation } from '@cats/src/validations/validations'
const schema = Yup.object().shape({
weight: weightValidation(hints)
})Validation Hints (i18n)
All validation messages support internationalization through hints:
const hints = {
inputRequiredMessage: t('forms.validation.required'),
emailValidationMessage: t('forms.validation.email'),
weightValidationMessage: t('forms.validation.weight')
}
const validationSchema = createValidationSchema(locale, hints)Common Patterns
1. Error Handling Helper
For array-based forms (like multiple cats), use the error helper utility:
import { errorHelper } from '@cats/locale/(onboarding)/(pages)/wizard/utilities/utilities'
const { error, touched, helperText } = errorHelper(
index,
'fieldName',
{ errors: formik.errors, touched: formik.touched }
)
<TextField
error={error && touched}
helperText={touched ? helperText : ''}
/>2. Field Arrays with Formik
For dynamic form arrays:
import { FieldArray, FieldArrayRenderProps } from 'formik'
<FieldArray
name="cats"
render={(arrayHelpers: FieldArrayRenderProps) => (
<div>
{formik.values.cats.map((cat, index) => (
<div key={index}>
<TextField name={`cats.${index}.name`} />
<Button onClick={() => arrayHelpers.remove(index)}>
Remove Cat
</Button>
</div>
))}
<Button onClick={() => arrayHelpers.push(emptyCat)}>
Add Cat
</Button>
</div>
)}
/>3. Async Form Submission
Handle async operations with loading states:
const handleSubmit = useCallback(
async (values: FormValues, { setSubmitting }: FormikProps<FormValues>) => {
setSubmitting(true)
try {
await submitMutation(values)
router.push('/success')
} catch (error) {
setSubmitting(false)
// Handle error
}
},
[submitMutation, router]
)
// In JSX
<LoadingButton
type="submit"
loading={formik.isSubmitting}
disabled={!formik.isValid}
>
Submit
</LoadingButton>File Organization
apps/cats/
├── app/[locale]/
│ ├── components/
│ │ ├── SteppedForm/ # Multi-step form wrapper
│ │ └── FormControlCard/ # Custom form controls
│ ├── (onboarding)/
│ │ └── (pages)/
│ │ ├── wizard/
│ │ │ ├── components/Form/ # Wizard form implementation
│ │ │ └── utilities/ # Form schemas & helpers
│ │ └── checkout/
│ │ ├── components/Form/ # Checkout form
│ │ └── utilities/ # Checkout schemas
│ └── account/
│ └── (modal)/settings/ # Account forms
└── src/validations/ # App-specific validations
packages/core/
└── utilities/validations.ts # Shared validation functionsBest Practices
1. TypeScript Integration
Always define TypeScript interfaces for form values:
type FormValues = {
firstName: string
email: string
preferences: {
newsletter: boolean
notifications: boolean
}
}2. Validation Schema Composition
Build complex schemas by composing simpler ones:
const baseUserSchema = Yup.object().shape({
firstName: firstNameValidation(hints),
lastName: lastNameValidation(hints)
})
const fullUserSchema = baseUserSchema.shape({
email: emailValidations(hints),
phone: phoneValidation(locale, hints)
})3. Form State Management
For complex forms, extract state management to custom hooks:
function useOnboardingForm() {
const { values, updateValues } = useOnboardingValues()
const validationSchema = useMemo(
() => createValidationSchema(locale, hints),
[locale, hints]
)
return {
initialValues: values,
validationSchema,
onSubmit: handleSubmit
}
}4. Error Handling
Implement consistent error handling patterns:
const handleSubmit = async (values, { setSubmitting, setFieldError }) => {
try {
await submitForm(values)
} catch (error) {
if (error.graphQLErrors) {
// Handle GraphQL field errors
error.graphQLErrors.forEach(({ extensions, message }) => {
if (extensions?.field) {
setFieldError(extensions.field, message)
}
})
}
setSubmitting(false)
}
}5. Testing Forms
Use data-testid attributes for reliable testing:
<Formik data-testid="wizard-form">
<TextField data-testid="first-name-input" />
<LoadingButton data-testid="submit-button" type="submit">
Submit
</LoadingButton>
</Formik>Integration with Material-UI
Forms integrate seamlessly with MUI components:
import { TextField, FormControlLabel, Checkbox } from '@mui/material'
import { useFormikContext } from 'formik'
function FormFields() {
const formik = useFormikContext<FormValues>()
return (
<>
<TextField
name="firstName"
value={formik.values.firstName}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.firstName && Boolean(formik.errors.firstName)}
helperText={formik.touched.firstName && formik.errors.firstName}
/>
<FormControlLabel
control={
<Checkbox
name="newsletter"
checked={formik.values.newsletter}
onChange={formik.handleChange}
/>
}
label="Subscribe to newsletter"
/>
</>
)
}Performance Considerations
1. Validation Performance
- Use
validationSchemaprop instead ofvalidatefunction for better performance - Memoize complex validation schemas with
useMemo - Debounce expensive async validations
2. Large Forms
- Split large forms into steps using
SteppedForm - Use
enableReinitialize={false}unless needed - Implement field-level validation for better UX
3. Dynamic Loading
Use dynamic imports for heavy form components:
const HeavyFormStep = dynamic(() => import('./HeavyFormStep'), {
ssr: false,
loading: () => <CircularProgress />
})