Skip to content
intermediate Phase 9 · Frontend Architecture

Form Architecture

Design scalable forms with validation, state management, and submission handling.

45m
0 problems
Topic Progress 0%

Form State Management

Form State Management

Efficiently manage form state, validation, and submission.

React Hook Form

import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
  age: z.number().min(18, 'Must be at least 18'),
  items: z.array(z.object({
    name: z.string().min(1),
    quantity: z.number().min(1),
  })),
});

function OrderForm() {
  const {
    register,
    handleSubmit,
    control,
    formState: { errors, isSubmitting, isDirty },
    watch,
    setValue,
  } = useForm({
    resolver: zodResolver(schema),
    defaultValues: {
      name: '',
      email: '',
      age: 0,
      items: [{ name: '', quantity: 1 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items',
  });

  const onSubmit = async (data) => {
    try {
      await submitOrder(data);
      showToast('Order submitted!');
    } catch (error) {
      showToast('Failed to submit order', 'error');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register('name')} />
        {errors.name && <span className="error">{errors.name.message}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input id="age" type="number" {...register('age', { valueAsNumber: true })} />
        {errors.age && <span className="error">{errors.age.message}</span>}
      </div>

      <div>
        <h3>Items</h3>
        {fields.map((field, index) => (
          <div key={field.id} className="item-row">
            <input
              {...register(`items.${index}.name`)}
              placeholder="Item name"
            />
            <input
              type="number"
              {...register(`items.${index}.quantity`, { valueAsNumber: true })}
            />
            <button type="button" onClick={() => remove(index)}>Remove</button>
          </div>
        ))}
        <button type="button" onClick={() => append({ name: '', quantity: 1 })}>
          Add Item
        </button>
      </div>

      <button type="submit" disabled={isSubmitting || !isDirty}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Formik

import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';

const validationSchema = Yup.object({
  firstName: Yup.string().required('Required'),
  lastName: Yup.string().required('Required'),
  email: Yup.string().email('Invalid email').required('Required'),
});

function SignupForm() {
  return (
    <Formik
      initialValues={{ firstName: '', lastName: '', email: '' }}
      validationSchema={validationSchema}
      onSubmit={(values, { setSubmitting }) => {
        setTimeout(() => {
          alert(JSON.stringify(values, null, 2));
          setSubmitting(false);
        }, 400);
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <div>
            <label htmlFor="firstName">First Name</label>
            <Field id="firstName" name="firstName" />
            <ErrorMessage name="firstName" component="span" className="error" />
          </div>

          <div>
            <label htmlFor="lastName">Last Name</label>
            <Field id="lastName" name="lastName" />
            <ErrorMessage name="lastName" component="span" className="error" />
          </div>

          <div>
            <label htmlFor="email">Email</label>
            <Field id="email" name="email" type="email" />
            <ErrorMessage name="email" component="span" className="error" />
          </div>

          <button type="submit" disabled={isSubmitting}>
            Submit
          </button>
        </Form>
      )}
    </Formik>
  );
}

Choosing a Library

Library Best For
React Hook Form Performance, minimal re-renders
Formik Simple forms, easy to learn
Final Form Subscription-based updates
Custom hooks Full control, specific needs

Validation Libraries

Validation Libraries

Zod

import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Invalid email'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain uppercase letter')
    .regex(/[0-9]/, 'Must contain number'),
  age: z.number().min(18).max(120),
  role: z.enum(['user', 'admin']),
  website: z.string().url().optional(),
  tags: z.array(z.string()).min(1, 'At least one tag required'),
});

// Inferred type
type User = z.infer<typeof userSchema>;

// Validate data
const result = userSchema.safeParse(formData);
if (!result.success) {
  console.error(result.error.format());
}

Yup

import * as Yup from 'yup';

const validationSchema = Yup.object({
  username: Yup.string()
    .required('Username is required')
    .min(3, 'Must be at least 3 characters')
    .max(20, 'Must be 20 characters or less'),
  email: Yup.string()
    .email('Invalid email address')
    .required('Email is required'),
  password: Yup.string()
    .required('Password is required')
    .min(8, 'Password must be at least 8 characters')
    .matches(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,
      'Must contain uppercase, lowercase, and number'
    ),
  confirmPassword: Yup.string()
    .oneOf([Yup.ref('password')], 'Passwords must match')
    .required('Please confirm your password'),
  birthDate: Yup.date()
    .max(new Date(), 'Birth date cannot be in the future')
    .required('Birth date is required'),
});

Custom Validation

// Async validation
const checkEmailUnique = async (email) => {
  const response = await fetch(`/api/check-email?email=${email}`);
  const { exists } = await response.json();
  return !exists;
};

const schemaWithAsync = z.object({
  email: z.string().email().refine(
    async (email) => await checkEmailUnique(email),
    'Email already in use'
  ),
});

// Cross-field validation
const passwordSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords do not match', path: ['confirmPassword'] }
);

Form Wizards

Form Wizards

Multi-step forms with progress tracking and state persistence.

Wizard Hook

// hooks/useFormWizard.js
function useFormWizard(steps) {
  const [currentStep, setCurrentStep] = useState(0);
  const [formData, setFormData] = useState({});
  const [errors, setErrors] = useState({});

  const updateData = useCallback((stepData) => {
    setFormData(prev => ({ ...prev, ...stepData }));
  }, []);

  const validateCurrentStep = useCallback(async () => {
    const step = steps[currentStep];
    if (!step.validate) return true;

    try {
      await step.validate(formData);
      setErrors(prev => ({ ...prev, [currentStep]: null }));
      return true;
    } catch (error) {
      setErrors(prev => ({ ...prev, [currentStep]: error }));
      return false;
    }
  }, [currentStep, steps, formData]);

  const nextStep = useCallback(async () => {
    const isValid = await validateCurrentStep();
    if (isValid && currentStep < steps.length - 1) {
      setCurrentStep(prev => prev + 1);
    }
  }, [currentStep, steps.length, validateCurrentStep]);

  const prevStep = useCallback(() => {
    if (currentStep > 0) {
      setCurrentStep(prev => prev - 1);
    }
  }, [currentStep]);

  const goToStep = useCallback(async (step) => {
    // Validate all steps before the target
    for (let i = 0; i < step; i++) {
      const isValid = await validateCurrentStep();
      if (!isValid) return;
    }
    setCurrentStep(step);
  }, [validateCurrentStep]);

  return {
    currentStep,
    totalSteps: steps.length,
    formData,
    errors,
    updateData,
    nextStep,
    prevStep,
    goToStep,
    isFirstStep: currentStep === 0,
    isLastStep: currentStep === steps.length - 1,
    progress: ((currentStep + 1) / steps.length) * 100,
  };
}

Wizard Component

// components/FormWizard.jsx
function FormWizard({ steps, onSubmit }) {
  const wizard = useFormWizard(steps);
  const CurrentStepComponent = steps[wizard.currentStep].component;

  return (
    <div className="form-wizard">
      <div className="wizard-progress">
        <div className="progress-bar" style={{ width: `${wizard.progress}%` }} />
        <div className="step-indicators">
          {steps.map((step, index) => (
            <button
              key={index}
              className={`step-indicator ${
                index === wizard.currentStep ? 'active' : ''
              } ${index < wizard.currentStep ? 'completed' : ''}`}
              onClick={() => wizard.goToStep(index)}
              disabled={index > wizard.currentStep}
            >
              {index + 1}
            </button>
          ))}
        </div>
      </div>

      <div className="wizard-content">
        <CurrentStepComponent
          data={wizard.formData}
          errors={wizard.errors[wizard.currentStep]}
          onChange={wizard.updateData}
        />
      </div>

      <div className="wizard-actions">
        {!wizard.isFirstStep && (
          <button onClick={wizard.prevStep}>Previous</button>
        )}
        {wizard.isLastStep ? (
          <button onClick={() => onSubmit(wizard.formData)}>
            Submit
          </button>
        ) : (
          <button onClick={wizard.nextStep}>Next</button>
        )}
      </div>
    </div>
  );
}

// Usage
const steps = [
  { component: PersonalInfoStep, validate: validatePersonalInfo },
  { component: AddressStep, validate: validateAddress },
  { component: PaymentStep, validate: validatePayment },
  { component: ReviewStep },
];

<FormWizard steps={steps} onSubmit={handleSubmit} />

State Persistence

// Persist wizard state in localStorage
function usePersistedWizard(steps) {
  const wizard = useFormWizard(steps);

  useEffect(() => {
    localStorage.setItem('wizardState', JSON.stringify({
      currentStep: wizard.currentStep,
      formData: wizard.formData,
    }));
  }, [wizard.currentStep, wizard.formData]);

  useEffect(() => {
    const saved = localStorage.getItem('wizardState');
    if (saved) {
      const state = JSON.parse(saved);
      // Restore state...
    }
  }, []);

  return wizard;
}

Quiz

1. What is the main advantage of React Hook Form?

Question 1 options

2. Why use Zod for validation?

Question 2 options

3. What is cross-field validation?

Question 3 options

4. How should form wizards handle navigation?

Question 4 options

5. Why persist wizard state?

Question 5 options

Flashcards

Question

What is the difference between React Hook Form and Formik?

Answer

React Hook Form uses uncontrolled components for performance; Formik uses controlled components for simplicity.

Question

What is Zod?

Answer

A TypeScript-first schema validation library that provides type inference from schemas.

Question

What is useFieldArray?

Answer

A React Hook Form hook for managing dynamic array fields like add/remove items.

Question

How do form wizards handle state?

Answer

They track current step, accumulate form data, and validate each step before proceeding.

Revision Notes

Key Takeaways

  • 1. React Hook Form offers better performance with uncontrolled components
  • 2. Zod provides TypeScript inference and composable validation schemas
  • 3. Always validate before allowing navigation in form wizards
  • 4. Persist wizard state to prevent data loss
  • 5. Use cross-field validation for related inputs

Interview Tips

  • Explain the difference between controlled and uncontrolled components
  • Discuss form validation strategies (client vs server)
  • Know how to implement multi-step forms with state management

Cheat Sheet

Form Architecture Cheat Sheet

React Hook Form

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(schema)
});

Zod Schema

const schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

Form Wizard

  • Track current step
  • Validate before proceeding
  • Accumulate form data
  • Persist state in localStorage

Key Concepts

  • Controlled vs uncontrolled components
  • Client vs server validation
  • Async validation
  • Cross-field validation