Episode 2 — React Frontend Architecture NextJS / 2.5 — Event Handling and Conditional Rendering

2.5.e — Practical Build: Dynamic Form with Validation

In one sentence: This practical builds a complete multi-step registration form from scratch — with controlled inputs, dynamic field rendering, real-time validation, error display, and submit handling — applying every concept from this topic.

Navigation: ← Dynamic UI Rendering · Next → 2.6 Component Architecture


Table of Contents

  1. What We're Building
  2. Project Setup — Form Architecture
  3. Step 1: The Form State Model
  4. Step 2: Validation Rules Engine
  5. Step 3: Reusable Form Field Components
  6. Step 4: Multi-Step Form Navigation
  7. Step 5: Step 1 — Personal Information
  8. Step 6: Step 2 — Account Details
  9. Step 7: Step 3 — Preferences
  10. Step 8: Step 4 — Review & Submit
  11. Step 9: Complete App Assembly
  12. Step 10: Adding Polish — Loading, Success, Error States
  13. Custom Hook: useForm
  14. Extending the Form — Adding/Removing Fields Dynamically
  15. Key Takeaways

1. What We're Building

A 4-step registration form with:

┌─────────────────────────────────────────────────┐
│  Step 1          Step 2          Step 3    Step 4│
│  ●───────────────○───────────────○──────────○   │
│  Personal Info   Account         Prefs     Review│
│                                                   │
│  ┌─────────────────────────────────────────────┐ │
│  │                                               │ │
│  │  First Name: [_______________] ✓              │ │
│  │  Last Name:  [_______________]                │ │
│  │  Email:      [_______________] ✗ Invalid      │ │
│  │  Phone:      [(___) ___-____]                 │ │
│  │                                               │ │
│  │  Errors shown in real-time                    │ │
│  │  Fields validated on blur                     │ │
│  │  Submit validates all fields                  │ │
│  │                                               │ │
│  │              [Back]  [Next →]                  │ │
│  └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘

Features:

  • Controlled inputs for every field type (text, email, password, select, checkbox, radio)
  • Real-time validation with error messages
  • "Touched" tracking — errors only show after field interaction
  • Multi-step navigation with per-step validation
  • Form state persists across steps
  • Review step showing all data before submission
  • Loading, success, and error states on submit
  • Phone number formatting as you type

2. Project Setup — Form Architecture

src/
├── components/
│   ├── FormField.jsx          # Reusable input wrapper
│   ├── StepIndicator.jsx      # Progress bar
│   ├── PersonalInfoStep.jsx   # Step 1
│   ├── AccountStep.jsx        # Step 2
│   ├── PreferencesStep.jsx    # Step 3
│   └── ReviewStep.jsx         # Step 4
├── hooks/
│   └── useForm.js             # Form state management hook
├── utils/
│   └── validators.js          # Validation rules
└── App.jsx                    # Main assembly

3. Step 1: The Form State Model

// Initial form state — covers all 4 steps
const initialFormState = {
  // Step 1: Personal Info
  firstName: '',
  lastName: '',
  email: '',
  phone: '',
  
  // Step 2: Account Details
  username: '',
  password: '',
  confirmPassword: '',
  
  // Step 3: Preferences
  role: '',
  experience: 'beginner',
  interests: [],
  newsletter: false,
  
  // Meta (not submitted)
  agreeToTerms: false,
};

Tracking Field State

// We need to track more than just values
const [values, setValues] = useState(initialFormState);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
┌─────────────── Form State ───────────────┐
│                                           │
│  values:  { email: "alice@" }             │
│  errors:  { email: "Invalid email" }      │
│  touched: { email: true, phone: false }   │
│                                           │
│  Show error for email? → YES              │
│    (touched AND has error)                │
│  Show error for phone? → NO              │
│    (not touched yet)                      │
└───────────────────────────────────────────┘

4. Step 2: Validation Rules Engine

// utils/validators.js

export const validators = {
  required: (value) => {
    if (typeof value === 'boolean') return value ? '' : 'This field is required';
    if (Array.isArray(value)) return value.length > 0 ? '' : 'Select at least one option';
    return value?.trim() ? '' : 'This field is required';
  },

  email: (value) => {
    if (!value) return '';
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(value) ? '' : 'Please enter a valid email address';
  },

  minLength: (min) => (value) => {
    if (!value) return '';
    return value.length >= min ? '' : `Must be at least ${min} characters`;
  },

  maxLength: (max) => (value) => {
    if (!value) return '';
    return value.length <= max ? '' : `Must be no more than ${max} characters`;
  },

  matches: (fieldName, fieldLabel) => (value, allValues) => {
    if (!value) return '';
    return value === allValues[fieldName] ? '' : `Must match ${fieldLabel}`;
  },

  phone: (value) => {
    if (!value) return '';
    const digits = value.replace(/\D/g, '');
    return digits.length === 10 ? '' : 'Please enter a 10-digit phone number';
  },

  password: (value) => {
    if (!value) return '';
    const checks = [];
    if (value.length < 8) checks.push('at least 8 characters');
    if (!/[A-Z]/.test(value)) checks.push('an uppercase letter');
    if (!/[a-z]/.test(value)) checks.push('a lowercase letter');
    if (!/[0-9]/.test(value)) checks.push('a number');
    return checks.length === 0 ? '' : `Password needs ${checks.join(', ')}`;
  },

  username: (value) => {
    if (!value) return '';
    if (value.length < 3) return 'Username must be at least 3 characters';
    if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Only letters, numbers, and underscores';
    return '';
  },
};

// Field validation config — which validators apply to each field
export const fieldValidation = {
  firstName:       [validators.required, validators.minLength(2)],
  lastName:        [validators.required, validators.minLength(2)],
  email:           [validators.required, validators.email],
  phone:           [validators.phone],
  username:        [validators.required, validators.username],
  password:        [validators.required, validators.password],
  confirmPassword: [validators.required, validators.matches('password', 'password')],
  role:            [validators.required],
  interests:       [validators.required],
  agreeToTerms:    [validators.required],
};

// Validate a single field
export function validateField(name, value, allValues) {
  const rules = fieldValidation[name] || [];
  for (const rule of rules) {
    const error = rule(value, allValues);
    if (error) return error; // Return first error
  }
  return '';
}

// Validate all fields in a step
export function validateStep(stepFields, values) {
  const errors = {};
  let isValid = true;

  for (const field of stepFields) {
    const error = validateField(field, values[field], values);
    if (error) {
      errors[field] = error;
      isValid = false;
    }
  }

  return { errors, isValid };
}

5. Step 3: Reusable Form Field Components

// components/FormField.jsx

function FormField({
  label,
  name,
  type = 'text',
  value,
  error,
  touched,
  onChange,
  onBlur,
  placeholder,
  options,     // For select/radio
  children,    // For custom content
  ...rest
}) {
  const showError = touched && error;
  const showSuccess = touched && !error && value;

  const inputStyle = {
    width: '100%',
    padding: '10px 12px',
    border: `2px solid ${showError ? '#dc3545' : showSuccess ? '#28a745' : '#dee2e6'}`,
    borderRadius: 8,
    fontSize: 14,
    outline: 'none',
    transition: 'border-color 0.2s',
    boxSizing: 'border-box',
  };

  function renderInput() {
    switch (type) {
      case 'textarea':
        return (
          <textarea
            name={name}
            value={value}
            onChange={onChange}
            onBlur={onBlur}
            placeholder={placeholder}
            style={{ ...inputStyle, resize: 'vertical', minHeight: 80 }}
            {...rest}
          />
        );

      case 'select':
        return (
          <select
            name={name}
            value={value}
            onChange={onChange}
            onBlur={onBlur}
            style={inputStyle}
            {...rest}
          >
            <option value="">{placeholder || '-- Select --'}</option>
            {options?.map(opt => (
              <option key={opt.value} value={opt.value}>{opt.label}</option>
            ))}
          </select>
        );

      case 'checkbox':
        return (
          <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
            <input
              type="checkbox"
              name={name}
              checked={value}
              onChange={onChange}
              onBlur={onBlur}
              {...rest}
            />
            <span>{label}</span>
          </label>
        );

      case 'radio':
        return (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            {options?.map(opt => (
              <label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
                <input
                  type="radio"
                  name={name}
                  value={opt.value}
                  checked={value === opt.value}
                  onChange={onChange}
                  onBlur={onBlur}
                />
                <span>{opt.label}</span>
              </label>
            ))}
          </div>
        );

      default:
        return (
          <input
            type={type}
            name={name}
            value={value}
            onChange={onChange}
            onBlur={onBlur}
            placeholder={placeholder}
            style={inputStyle}
            {...rest}
          />
        );
    }
  }

  return (
    <div style={{ marginBottom: 16 }}>
      {type !== 'checkbox' && (
        <label
          htmlFor={name}
          style={{ display: 'block', marginBottom: 6, fontWeight: 500, fontSize: 14 }}
        >
          {label}
        </label>
      )}

      {renderInput()}

      {showError && (
        <p style={{ color: '#dc3545', fontSize: 12, margin: '4px 0 0', display: 'flex', alignItems: 'center', gap: 4 }}>
          ⚠ {error}
        </p>
      )}

      {children}
    </div>
  );
}

6. Step 4: Multi-Step Form Navigation

// components/StepIndicator.jsx

function StepIndicator({ steps, currentStep }) {
  return (
    <div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
      {steps.map((step, index) => {
        const isCompleted = index < currentStep;
        const isCurrent = index === currentStep;
        const isUpcoming = index > currentStep;

        return (
          <div key={index} style={{ display: 'flex', alignItems: 'center', flex: index < steps.length - 1 ? 1 : 'none' }}>
            {/* Step Circle */}
            <div style={{
              width: 36,
              height: 36,
              borderRadius: '50%',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              fontWeight: 600,
              fontSize: 14,
              background: isCompleted ? '#28a745' : isCurrent ? '#0070f3' : '#e9ecef',
              color: isCompleted || isCurrent ? 'white' : '#6c757d',
              transition: 'all 0.3s',
            }}>
              {isCompleted ? '✓' : index + 1}
            </div>

            {/* Step Label */}
            <span style={{
              marginLeft: 8,
              fontSize: 13,
              fontWeight: isCurrent ? 600 : 400,
              color: isUpcoming ? '#adb5bd' : '#212529',
            }}>
              {step}
            </span>

            {/* Connector Line */}
            {index < steps.length - 1 && (
              <div style={{
                flex: 1,
                height: 2,
                margin: '0 12px',
                background: isCompleted ? '#28a745' : '#e9ecef',
                transition: 'background 0.3s',
              }} />
            )}
          </div>
        );
      })}
    </div>
  );
}

7. Step 5: Step 1 — Personal Information

// components/PersonalInfoStep.jsx

function PersonalInfoStep({ values, errors, touched, onChange, onBlur }) {
  // Phone formatter
  function handlePhoneChange(event) {
    let digits = event.target.value.replace(/\D/g, '').slice(0, 10);
    let formatted = '';
    if (digits.length > 0) formatted = '(' + digits.slice(0, 3);
    if (digits.length >= 3) formatted += ') ' + digits.slice(3, 6);
    if (digits.length >= 6) formatted += '-' + digits.slice(6);

    // Create synthetic event with formatted value
    onChange({
      target: { name: 'phone', value: formatted, type: 'text' }
    });
  }

  return (
    <div>
      <h2 style={{ marginBottom: 4 }}>Personal Information</h2>
      <p style={{ color: '#6c757d', marginBottom: 24 }}>Tell us about yourself</p>

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
        <FormField
          label="First Name"
          name="firstName"
          value={values.firstName}
          error={errors.firstName}
          touched={touched.firstName}
          onChange={onChange}
          onBlur={onBlur}
          placeholder="John"
        />
        <FormField
          label="Last Name"
          name="lastName"
          value={values.lastName}
          error={errors.lastName}
          touched={touched.lastName}
          onChange={onChange}
          onBlur={onBlur}
          placeholder="Doe"
        />
      </div>

      <FormField
        label="Email Address"
        name="email"
        type="email"
        value={values.email}
        error={errors.email}
        touched={touched.email}
        onChange={onChange}
        onBlur={onBlur}
        placeholder="john@example.com"
      />

      <FormField
        label="Phone Number (optional)"
        name="phone"
        type="tel"
        value={values.phone}
        error={errors.phone}
        touched={touched.phone}
        onChange={handlePhoneChange}
        onBlur={onBlur}
        placeholder="(555) 123-4567"
      />
    </div>
  );
}

8. Step 6: Step 2 — Account Details

// components/AccountStep.jsx

function AccountStep({ values, errors, touched, onChange, onBlur }) {
  // Password strength indicator
  function getPasswordStrength(pw) {
    let score = 0;
    if (pw.length >= 8) score++;
    if (pw.length >= 12) score++;
    if (/[A-Z]/.test(pw)) score++;
    if (/[a-z]/.test(pw)) score++;
    if (/[0-9]/.test(pw)) score++;
    if (/[^A-Za-z0-9]/.test(pw)) score++;
    return score;
  }

  const strength = getPasswordStrength(values.password);
  const strengthLabels = ['', 'Very Weak', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];
  const strengthColors = ['#ccc', '#dc3545', '#fd7e14', '#ffc107', '#20c997', '#28a745', '#198754'];

  return (
    <div>
      <h2 style={{ marginBottom: 4 }}>Account Details</h2>
      <p style={{ color: '#6c757d', marginBottom: 24 }}>Set up your login credentials</p>

      <FormField
        label="Username"
        name="username"
        value={values.username}
        error={errors.username}
        touched={touched.username}
        onChange={onChange}
        onBlur={onBlur}
        placeholder="johndoe"
      />

      <FormField
        label="Password"
        name="password"
        type="password"
        value={values.password}
        error={errors.password}
        touched={touched.password}
        onChange={onChange}
        onBlur={onBlur}
        placeholder="At least 8 characters"
      >
        {/* Password strength bar */}
        {values.password && (
          <div style={{ marginTop: 8 }}>
            <div style={{ height: 4, borderRadius: 2, background: '#e9ecef' }}>
              <div style={{
                height: '100%',
                width: `${(strength / 6) * 100}%`,
                background: strengthColors[strength],
                borderRadius: 2,
                transition: 'all 0.3s',
              }} />
            </div>
            <span style={{ fontSize: 12, color: strengthColors[strength] }}>
              {strengthLabels[strength]}
            </span>
          </div>
        )}
      </FormField>

      <FormField
        label="Confirm Password"
        name="confirmPassword"
        type="password"
        value={values.confirmPassword}
        error={errors.confirmPassword}
        touched={touched.confirmPassword}
        onChange={onChange}
        onBlur={onBlur}
        placeholder="Re-enter your password"
      />
    </div>
  );
}

9. Step 7: Step 3 — Preferences

// components/PreferencesStep.jsx

function PreferencesStep({ values, errors, touched, onChange, onBlur }) {
  const roleOptions = [
    { value: 'developer', label: 'Developer' },
    { value: 'designer', label: 'Designer' },
    { value: 'manager', label: 'Product Manager' },
    { value: 'student', label: 'Student' },
    { value: 'other', label: 'Other' },
  ];

  const experienceOptions = [
    { value: 'beginner', label: 'Beginner (< 1 year)' },
    { value: 'intermediate', label: 'Intermediate (1-3 years)' },
    { value: 'advanced', label: 'Advanced (3-5 years)' },
    { value: 'expert', label: 'Expert (5+ years)' },
  ];

  const interestOptions = ['React', 'Vue', 'Angular', 'Svelte', 'Next.js', 'Node.js', 'Python', 'Go'];

  function handleInterestChange(event) {
    const { value, checked } = event.target;
    const newInterests = checked
      ? [...values.interests, value]
      : values.interests.filter(i => i !== value);
    
    onChange({
      target: { name: 'interests', value: newInterests, type: 'checkbox-group' }
    });
  }

  return (
    <div>
      <h2 style={{ marginBottom: 4 }}>Preferences</h2>
      <p style={{ color: '#6c757d', marginBottom: 24 }}>Help us personalize your experience</p>

      <FormField
        label="Your Role"
        name="role"
        type="select"
        value={values.role}
        error={errors.role}
        touched={touched.role}
        onChange={onChange}
        onBlur={onBlur}
        placeholder="Select your role"
        options={roleOptions}
      />

      <FormField
        label="Experience Level"
        name="experience"
        type="radio"
        value={values.experience}
        error={errors.experience}
        touched={touched.experience}
        onChange={onChange}
        onBlur={onBlur}
        options={experienceOptions}
      />

      <div style={{ marginBottom: 16 }}>
        <label style={{ display: 'block', marginBottom: 8, fontWeight: 500, fontSize: 14 }}>
          Interests (select at least one)
        </label>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
          {interestOptions.map(interest => (
            <label key={interest} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
              <input
                type="checkbox"
                value={interest}
                checked={values.interests.includes(interest)}
                onChange={handleInterestChange}
              />
              {interest}
            </label>
          ))}
        </div>
        {touched.interests && errors.interests && (
          <p style={{ color: '#dc3545', fontSize: 12, marginTop: 4 }}>⚠ {errors.interests}</p>
        )}
      </div>

      <FormField
        label="Subscribe to our newsletter"
        name="newsletter"
        type="checkbox"
        value={values.newsletter}
        onChange={onChange}
        onBlur={onBlur}
      />
    </div>
  );
}

10. Step 8: Step 4 — Review & Submit

// components/ReviewStep.jsx

function ReviewStep({ values, errors, touched, onChange, onBlur }) {
  const sections = [
    {
      title: 'Personal Information',
      fields: [
        { label: 'Name', value: `${values.firstName} ${values.lastName}` },
        { label: 'Email', value: values.email },
        { label: 'Phone', value: values.phone || 'Not provided' },
      ],
    },
    {
      title: 'Account Details',
      fields: [
        { label: 'Username', value: values.username },
        { label: 'Password', value: '•'.repeat(values.password.length) },
      ],
    },
    {
      title: 'Preferences',
      fields: [
        { label: 'Role', value: values.role },
        { label: 'Experience', value: values.experience },
        { label: 'Interests', value: values.interests.join(', ') || 'None' },
        { label: 'Newsletter', value: values.newsletter ? 'Yes' : 'No' },
      ],
    },
  ];

  return (
    <div>
      <h2 style={{ marginBottom: 4 }}>Review Your Information</h2>
      <p style={{ color: '#6c757d', marginBottom: 24 }}>
        Please verify everything looks correct before submitting.
      </p>

      {sections.map(section => (
        <div
          key={section.title}
          style={{
            border: '1px solid #e9ecef',
            borderRadius: 8,
            padding: 16,
            marginBottom: 16,
          }}
        >
          <h3 style={{ margin: '0 0 12px', fontSize: 16, color: '#495057' }}>
            {section.title}
          </h3>
          {section.fields.map(field => (
            <div
              key={field.label}
              style={{
                display: 'flex',
                justifyContent: 'space-between',
                padding: '8px 0',
                borderBottom: '1px solid #f8f9fa',
              }}
            >
              <span style={{ color: '#6c757d', fontSize: 14 }}>{field.label}</span>
              <span style={{ fontWeight: 500, fontSize: 14 }}>{field.value}</span>
            </div>
          ))}
        </div>
      ))}

      <FormField
        label="I agree to the Terms of Service and Privacy Policy"
        name="agreeToTerms"
        type="checkbox"
        value={values.agreeToTerms}
        error={errors.agreeToTerms}
        touched={touched.agreeToTerms}
        onChange={onChange}
        onBlur={onBlur}
      />
    </div>
  );
}

11. Step 9: Complete App Assembly

// App.jsx
import { useState } from 'react';
import { validateField, validateStep } from './utils/validators';

// Step field mapping
const stepFields = {
  0: ['firstName', 'lastName', 'email', 'phone'],
  1: ['username', 'password', 'confirmPassword'],
  2: ['role', 'interests'],
  3: ['agreeToTerms'],
};

const stepLabels = ['Personal', 'Account', 'Preferences', 'Review'];

function App() {
  const [currentStep, setCurrentStep] = useState(0);
  const [values, setValues] = useState(initialFormState);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [submitStatus, setSubmitStatus] = useState('idle'); // idle | loading | success | error

  // ─── Handlers ───

  function handleChange(event) {
    const { name, value, type, checked } = event.target;
    const newValue = type === 'checkbox' ? checked : value;

    setValues(prev => ({ ...prev, [name]: newValue }));

    // Validate on change if already touched
    if (touched[name]) {
      const error = validateField(name, newValue, { ...values, [name]: newValue });
      setErrors(prev => ({ ...prev, [name]: error }));
    }
  }

  function handleBlur(event) {
    const { name } = event.target;
    setTouched(prev => ({ ...prev, [name]: true }));

    // Validate on blur
    const error = validateField(name, values[name], values);
    setErrors(prev => ({ ...prev, [name]: error }));
  }

  function handleNext() {
    const fields = stepFields[currentStep];
    const { errors: stepErrors, isValid } = validateStep(fields, values);

    // Mark all step fields as touched
    const newTouched = {};
    fields.forEach(f => { newTouched[f] = true; });
    setTouched(prev => ({ ...prev, ...newTouched }));
    setErrors(prev => ({ ...prev, ...stepErrors }));

    if (isValid) {
      setCurrentStep(prev => prev + 1);
    }
  }

  function handleBack() {
    setCurrentStep(prev => prev - 1);
  }

  async function handleSubmit() {
    // Validate final step
    const fields = stepFields[currentStep];
    const { errors: stepErrors, isValid } = validateStep(fields, values);

    const newTouched = {};
    fields.forEach(f => { newTouched[f] = true; });
    setTouched(prev => ({ ...prev, ...newTouched }));
    setErrors(prev => ({ ...prev, ...stepErrors }));

    if (!isValid) return;

    setSubmitStatus('loading');

    try {
      // Simulate API call
      await new Promise((resolve, reject) => {
        setTimeout(() => {
          if (Math.random() > 0.1) resolve();
          else reject(new Error('Server error'));
        }, 2000);
      });
      setSubmitStatus('success');
    } catch (err) {
      setSubmitStatus('error');
    }
  }

  // ─── Common props passed to all step components ───
  const formProps = { values, errors, touched, onChange: handleChange, onBlur: handleBlur };

  // ─── Render ───

  if (submitStatus === 'success') {
    return (
      <div style={{ textAlign: 'center', padding: 60 }}>
        <div style={{ fontSize: 64, marginBottom: 16 }}>🎉</div>
        <h2>Registration Complete!</h2>
        <p style={{ color: '#6c757d' }}>Welcome, {values.firstName}! Check your email for next steps.</p>
      </div>
    );
  }

  const stepComponents = [
    <PersonalInfoStep {...formProps} />,
    <AccountStep {...formProps} />,
    <PreferencesStep {...formProps} />,
    <ReviewStep {...formProps} />,
  ];

  const isLastStep = currentStep === stepComponents.length - 1;

  return (
    <div style={{ maxWidth: 600, margin: '40px auto', padding: '0 20px' }}>
      <h1 style={{ textAlign: 'center', marginBottom: 32 }}>Create Account</h1>

      <StepIndicator steps={stepLabels} currentStep={currentStep} />

      <div style={{
        background: 'white',
        border: '1px solid #e9ecef',
        borderRadius: 12,
        padding: 32,
      }}>
        {stepComponents[currentStep]}

        {submitStatus === 'error' && (
          <div style={{
            background: '#f8d7da',
            color: '#721c24',
            padding: 12,
            borderRadius: 8,
            marginBottom: 16,
          }}>
            ❌ Something went wrong. Please try again.
          </div>
        )}

        <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
          <button
            onClick={handleBack}
            disabled={currentStep === 0}
            style={{
              padding: '10px 24px',
              border: '1px solid #dee2e6',
              borderRadius: 8,
              background: 'white',
              cursor: currentStep === 0 ? 'not-allowed' : 'pointer',
              opacity: currentStep === 0 ? 0.5 : 1,
            }}
          >
            ← Back
          </button>

          <button
            onClick={isLastStep ? handleSubmit : handleNext}
            disabled={submitStatus === 'loading'}
            style={{
              padding: '10px 24px',
              border: 'none',
              borderRadius: 8,
              background: '#0070f3',
              color: 'white',
              cursor: 'pointer',
              fontWeight: 600,
              opacity: submitStatus === 'loading' ? 0.7 : 1,
            }}
          >
            {submitStatus === 'loading'
              ? 'Submitting...'
              : isLastStep
                ? 'Submit'
                : 'Next →'
            }
          </button>
        </div>
      </div>
    </div>
  );
}

12. Step 10: Adding Polish — Loading, Success, Error States

Submit Button with Loading Spinner

function SubmitButton({ isLoading, isLastStep, onClick }) {
  return (
    <button
      onClick={onClick}
      disabled={isLoading}
      style={{
        padding: '10px 24px',
        border: 'none',
        borderRadius: 8,
        background: '#0070f3',
        color: 'white',
        cursor: isLoading ? 'wait' : 'pointer',
        fontWeight: 600,
        display: 'flex',
        alignItems: 'center',
        gap: 8,
      }}
    >
      {isLoading && (
        <span style={{
          width: 16,
          height: 16,
          border: '2px solid rgba(255,255,255,0.3)',
          borderTopColor: 'white',
          borderRadius: '50%',
          display: 'inline-block',
          animation: 'spin 0.8s linear infinite',
        }} />
      )}
      {isLoading ? 'Submitting...' : isLastStep ? 'Submit' : 'Next →'}
    </button>
  );
}

13. Custom Hook: useForm

Extract all form logic into a reusable hook:

// hooks/useForm.js
import { useState, useCallback } from 'react';

export function useForm(initialValues, validationRules) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validateField = useCallback((name, value) => {
    const rules = validationRules[name] || [];
    for (const rule of rules) {
      const error = rule(value, values);
      if (error) return error;
    }
    return '';
  }, [validationRules, values]);

  const handleChange = useCallback((event) => {
    const { name, value, type, checked } = event.target;
    const newValue = type === 'checkbox' ? checked : value;

    setValues(prev => {
      const updated = { ...prev, [name]: newValue };
      // Re-validate if touched
      if (touched[name]) {
        const error = validateField(name, newValue);
        setErrors(prev => ({ ...prev, [name]: error }));
      }
      return updated;
    });
  }, [touched, validateField]);

  const handleBlur = useCallback((event) => {
    const { name } = event.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    const error = validateField(name, values[name]);
    setErrors(prev => ({ ...prev, [name]: error }));
  }, [validateField, values]);

  const validateFields = useCallback((fieldNames) => {
    const newErrors = {};
    let isValid = true;
    const newTouched = {};

    fieldNames.forEach(name => {
      newTouched[name] = true;
      const error = validateField(name, values[name]);
      if (error) {
        newErrors[name] = error;
        isValid = false;
      }
    });

    setTouched(prev => ({ ...prev, ...newTouched }));
    setErrors(prev => ({ ...prev, ...newErrors }));
    return isValid;
  }, [validateField, values]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    setIsSubmitting,
    handleChange,
    handleBlur,
    validateFields,
    setValues,
    reset,
  };
}

// Usage:
// const form = useForm(initialFormState, fieldValidation);
// <FormField value={form.values.email} error={form.errors.email} ... />

14. Extending the Form — Adding/Removing Fields Dynamically

function DynamicFieldsDemo() {
  const [fields, setFields] = useState([{ id: 1, value: '' }]);

  function addField() {
    setFields(prev => [...prev, { id: Date.now(), value: '' }]);
  }

  function removeField(id) {
    setFields(prev => prev.filter(f => f.id !== id));
  }

  function updateField(id, value) {
    setFields(prev => prev.map(f => f.id === id ? { ...f, value } : f));
  }

  return (
    <div>
      <h3>Skills</h3>
      {fields.map((field, index) => (
        <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
          <input
            value={field.value}
            onChange={(e) => updateField(field.id, e.target.value)}
            placeholder={`Skill ${index + 1}`}
            style={{ flex: 1 }}
          />
          {fields.length > 1 && (
            <button onClick={() => removeField(field.id)} style={{ color: 'red' }}>
              ✕
            </button>
          )}
        </div>
      ))}
      <button onClick={addField} style={{ marginTop: 8 }}>
        + Add Skill
      </button>
    </div>
  );
}

15. Key Takeaways

  1. Form architecture = state model + validation rules + reusable field components + step navigation
  2. Controlled inputs give complete control over every value, enabling real-time validation and formatting
  3. Validation on blur (not on every keystroke) provides the best UX — errors appear after the user finishes a field
  4. Validate on change only for fields that have already been touched (re-validation)
  5. Per-step validation prevents users from advancing with invalid data
  6. The name attribute is the key to a single handleChange for all fields
  7. FormField component abstracts away error display, styling, and input type variations
  8. Custom useForm hook extracts all form logic — makes it reusable across your entire app
  9. Dynamic fields (add/remove) work by managing an array of field objects with unique IDs
  10. Always handle loading, success, AND error states on form submission

Explain-It Challenge

  1. The Product Manager Explanation: Explain to a PM why form validation happens on blur (not every keystroke) and why per-step validation matters for conversion rates.

  2. The Architecture Discussion: Compare our custom useForm hook to libraries like React Hook Form and Formik. What trade-offs do you make building your own vs using a library?

  3. The Refactoring Challenge: The current form has all step components in separate files. How would you restructure it to be config-driven — where adding a new step requires only adding an entry to a configuration object, not writing a new component?


Navigation: ← Dynamic UI Rendering · Next → 2.6 Component Architecture