Forms

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 functions

Best 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 validationSchema prop instead of validate function 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 />
})