Skip to content

Instantly share code, notes, and snippets.

@psenger
Last active June 13, 2025 12:48
Show Gist options
  • Save psenger/1b746ff522b3ab89ec55377a855c2d8b to your computer and use it in GitHub Desktop.
Save psenger/1b746ff522b3ab89ec55377a855c2d8b to your computer and use it in GitHub Desktop.
[Dynamic-Computed Property Name Pattern] #ReactJs #JavaScript

The Ultimate React Form Handler Pattern: Clean, Reusable, and Type-Safe

Form handling in React can quickly become verbose and repetitive. The Dynamic Property Name pattern (sometimes called the "single handler pattern") elegantly solves this problem by combining several modern JavaScript features. Let me walk you through several iterations of this powerful pattern, its benefits, and some enhancements.

The Core Pattern with Pure JavaScript

  1. DRY Code: A single handler function handles all input changes, eliminating redundant code.
  2. Self-Documenting: The input's name attribute directly corresponds to the state property, making the relationship clear.
  3. Scalability: Adding new form fields requires minimal code changes - just add a new input with the appropriate name.
  4. Maintainability: Changes to validation or processing logic happen in one place.
  5. Immutability: Uses the spread operator to create a new state object rather than mutating the existing one.
const [person, setPerson] = useState({
  firstName: 'Barbara',
  lastName: 'Hepworth',
  email: '[email protected]'
});

function handleChange(e) {
  const { name, value } = e.target;
  setPerson({
    ...person,
    [name]: value
  });
}

return (
  <form>
    <input
      name="firstName"
      value={person.firstName}
      onChange={handleChange}
    />
    {/* More inputs following the same pattern */}
  </form>
);

Enhanced Core Pattern with Typescript and ZOD

  1. Type Safety: TypeScript ensures your state and event handlers are properly typed.
  2. Input Validation: Integrated form validation that updates error state.
  3. Accessibility: Proper labels with htmlFor attributes and semantic HTML.
  4. Functional Updates: Using the callback form of setState setPerson(prev => ...) to avoid closure issues.
  5. Different Input Types: Handling checkbox, number, and text inputs appropriately.
  6. Visual Feedback: Error states reflected in the UI with appropriate styling.
  7. Performance: Clearing errors as soon as fields are corrected, giving immediate feedback.
import { useState, ChangeEvent } from 'react'
import { z } from 'zod'

// Define a schema for strong type validation
const PersonSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string(),
  email: z.string().email('Invalid email address'),
  age: z.number().positive('Age must be a positive number')
})

// Derive TypeScript type from the Zod schema
type Person = z.infer<typeof PersonSchema>

export default function PersonForm() {
  // Initialize form state with default values
  const [person, setPerson] = useState<Person>({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: '[email protected]',
    age: 42
  })
  
  // Track validation errors for each field
  const [errors, setErrors] = useState<Partial<Record<keyof Person, string>>>({})
  
  // Track form submission status
  const [isSubmitting, setIsSubmitting] = useState(false)

  /**
   * Universal change handler for all form inputs
   * Uses computed property names to update the correct field
   * Handles different input types appropriately (text, number, checkbox)
   */
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value, type, checked } = e.target
    
    // Determine the new value based on input type
    const newValue = 
      type === 'checkbox' ? checked :     // For checkboxes, use the checked state
      type === 'number' ? parseFloat(value) || 0 : // For numbers, parse to float (or 0 if invalid)
      value                              // For text/email/etc, use the string value
    
    // Update state using functional update to avoid closure issues
    setPerson(prevPerson => ({
      ...prevPerson,
      [name]: newValue
    }))
    
    // Clear error when field is modified to provide immediate feedback
    if (errors[name as keyof Person]) {
      setErrors(prevErrors => ({
        ...prevErrors,
        [name]: undefined
      }))
    }
  }
  
  /**
   * Validates the entire form using Zod schema
   * Returns boolean indicating if form is valid
   * Updates error state with any validation errors
   */
  const validateForm = (): boolean => {
    try {
      // Attempt to parse/validate the current form data
      PersonSchema.parse(person)
      setErrors({})  // Clear all errors if valid
      return true
    } catch (error) {
      // If validation fails, format and set error messages
      if (error instanceof z.ZodError) {
        const newErrors: Partial<Record<keyof Person, string>> = {}
        
        error.errors.forEach(err => {
          // Extract the field path and error message
          const field = err.path[0] as keyof Person
          newErrors[field] = err.message
        })
        
        setErrors(newErrors)
      }
      return false
    }
  }
  
  /**
   * Form submission handler
   * Prevents default form behavior, validates, and processes data
   */
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)
    
    if (validateForm()) {
      try {
        // Simulate API call with delay
        await new Promise(resolve => setTimeout(resolve, 500))
        console.log('Form submitted successfully:', person)
        
        // Here you would typically:
        // - Send data to server
        // - Display success message
        // - Reset form or redirect
      } catch (error) {
        console.error('Submission error:', error)
        // Handle submission error
      }
    }
    
    setIsSubmitting(false)
  }

  /**
   * Renders an input field with label and error message
   * Reusable component to maintain consistency
   */
  const renderField = (
    id: keyof Person,
    label: string,
    type: string = 'text',
    required: boolean = false
  ) => (
    <div>
      <label htmlFor={id}>
        {label}{required && <span className='required'>*</span>}
      </label>
      <input
        id={id}
        name={id}
        type={type}
        value={person[id].toString()}
        onChange={handleChange}
        className={errors[id] ? 'error' : ''}
        required={required}
      />
      {errors[id] && <p className='error-message'>{errors[id]}</p>}
    </div>
  )

  return (
    <form onSubmit={handleSubmit}>
      {/* Render form fields using our helper function */}
      {renderField('firstName', 'First Name', 'text', true)}
      {renderField('lastName', 'Last Name')}
      {renderField('email', 'Email Address', 'email', true)}
      {renderField('age', 'Age', 'number', true)}
      
      <button 
        type='submit' 
        disabled={isSubmitting}
      >
        {isSubmitting ? 'Saving...' : 'Save'}
      </button>
    </form>
  )
}

Enhanced Core Pattern with Typescript, ZOD, and React Hook Form

  1. Simplified State Management: React Hook Form handles form state internally, eliminating the need for multiple useState hooks.
  2. Automatic Form Validation: Form validation happens automatically on submit and can be configured for different events (blur, change, etc.).
  3. Performance Optimization: React Hook Form minimizes re-renders by isolating field updates.
  4. Declarative API: The register function elegantly connects form fields to validation and state.
  5. Advanced Field Control: The Controller component provides more flexibility for complex inputs.
  6. Built-in Submission State: isSubmitting is provided directly by the library.
  7. Zod Integration: Using @hookform/resolvers/zod for seamless schema validation.
  8. Type Safety: TypeScript types are derived from the Zod schema, ensuring consistency.
  9. Value Transformation: Proper handling of number inputs with valueAsNumber option.
  10. Less Code, More Functionality: Overall reduction in code while maintaining or improving features.
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// Define a schema for validation
const PersonSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string(),
  email: z.string().email('Invalid email address'),
  age: z.number().positive('Age must be a positive number').or(z.string().regex(/^\d+$/).transform(Number))
})

// Derive TypeScript type from the Zod schema
type Person = z.infer<typeof PersonSchema>

export default function PersonForm() {
  /**
   * Initialize React Hook Form with zod resolver
   * defaultValues provides initial form data
   */
  const {
    control,
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<Person>({
    resolver: zodResolver(PersonSchema),
    defaultValues: {
      firstName: 'Barbara',
      lastName: 'Hepworth',
      email: '[email protected]',
      age: 42
    }
  })
  
  /**
   * Form submission handler
   * Data is already validated by React Hook Form + Zod
   */
  const onSubmit = async (data: Person) => {
    try {
      // Simulate API call with delay
      await new Promise(resolve => setTimeout(resolve, 500))
      console.log('Form submitted successfully:', data)
      
      // Here you would typically:
      // - Send data to server
      // - Display success message
      // - Reset form or redirect
    } catch (error) {
      console.error('Submission error:', error)
      // Handle submission error
    }
  }

  /**
   * Renders an input field with label and error message
   * Reusable component to maintain consistency
   */
  const renderField = (
    id: keyof Person,
    label: string,
    type: string = 'text',
    required: boolean = false
  ) => (
    <div>
      <label htmlFor={id}>
        {label}{required && <span className='required'>*</span>}
      </label>
      <input
        id={id}
        type={type}
        {...register(id, { valueAsNumber: type === 'number' })}
        className={errors[id] ? 'error' : ''}
      />
      {errors[id] && <p className='error-message'>{errors[id]?.message?.toString()}</p>}
    </div>
  )

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Render basic text and email fields using our helper function */}
      {renderField('firstName', 'First Name', 'text', true)}
      {renderField('lastName', 'Last Name')}
      {renderField('email', 'Email Address', 'email', true)}
      
      {/* For the number field, we use Controller for more control */}
      <div>
        <label htmlFor='age'>
          Age<span className='required'>*</span>
        </label>
        <Controller
          name='age'
          control={control}
          render={({ field }) => (
            <input
              id='age'
              type='number'
              onChange={(e) => field.onChange(e.target.valueAsNumber || 0)}
              value={field.value}
              className={errors.age ? 'error' : ''}
            />
          )}
        />
        {errors.age && <p className='error-message'>{errors.age.message?.toString()}</p>}
      </div>
      
      <button 
        type='submit' 
        disabled={isSubmitting}
      >
        {isSubmitting ? 'Saving...' : 'Save'}
      </button>
    </form>
  )
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment