Back to Blog
·5 min read

Why I stopped using React's useState for complex form validation

useState feels natural for forms, but it creates a tangled mess of validation logic that's hard to test and maintain.

AI Dev
react
forms
state-management
validation

Why I stopped using React's useState for complex form validation

I used to reach for useState immediately when building forms. It felt like the React way -- one state hook for each field, maybe a few more for validation errors and loading states. The pattern was familiar: const [email, setEmail] = useState(''), const [emailError, setEmailError] = useState(''), repeat for every field. Simple forms worked fine. Then I built a multi-step user onboarding flow with conditional validation, dependent fields, and async server-side checks. My component ballooned to 400 lines of intertwined state updates and validation logic.

The breaking point came when I needed to add a "confirm email" field that validated against the original email in real-time. I had validation logic scattered across multiple useEffect hooks, state updates that triggered other state updates, and race conditions between user typing and async validation. Adding one field required touching eight different pieces of state logic. When a QA engineer found a bug where clearing the email field did not clear the dependent validation errors, I spent two hours tracing through the state dependency chain.

That's when I learned that useState optimizes for simple local state, not coordinated validation logic. Complex forms need state machines, not scattered boolean flags.

The useState validation trap

Here's what my form components used to look like:

function UserRegistrationForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [confirmPassword, setConfirmPassword] = useState('')
  const [emailError, setEmailError] = useState('')
  const [passwordError, setPasswordError] = useState('')
  const [confirmPasswordError, setConfirmPasswordError] = useState('')
  const [isValidating, setIsValidating] = useState(false)
  const [isEmailAvailable, setIsEmailAvailable] = useState(null)
 
  useEffect(() => {
    if (email && !emailError) {
      setIsValidating(true)
      checkEmailAvailability(email).then(available => {
        setIsEmailAvailable(available)
        if (!available) {
          setEmailError('Email already in use')
        }
        setIsValidating(false)
      })
    }
  }, [email, emailError])
 
  useEffect(() => {
    if (confirmPassword && confirmPassword !== password) {
      setConfirmPasswordError('Passwords do not match')
    } else {
      setConfirmPasswordError('')
    }
  }, [password, confirmPassword])
 
  // ... more validation effects and handlers
}

This approach creates several problems:

  • State synchronization bugs -- validation logic depends on multiple pieces of state that can get out of sync
  • Race conditions -- async validation can complete out of order
  • Testing complexity -- you need to mock multiple state updates and effects
  • Cognitive overhead -- understanding form behavior requires tracing through multiple effects

What I use now: useReducer with validation schemas

I switched to useReducer combined with a validation schema approach. Instead of managing individual state pieces, I model the entire form as a single state machine:

interface FormState {
  fields: {
    email: string
    password: string
    confirmPassword: string
  }
  errors: Record<string, string>
  isValidating: Set<string>
  touched: Set<string>
}
 
type FormAction = 
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'SET_VALIDATING'; field: string; validating: boolean }
  | { type: 'SET_TOUCHED'; field: string }
 
function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        fields: { ...state.fields, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' }
      }
    case 'SET_ERROR':
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.error }
      }
    // ... other cases
  }
}
 
function UserRegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState)
  
  const validateField = useCallback((field: string, value: string) => {
    // Centralized validation logic
    const schema = getValidationSchema()
    const error = schema.validateField(field, value, state.fields)
    dispatch({ type: 'SET_ERROR', field, error })
  }, [state.fields])
 
  const handleFieldChange = (field: string, value: string) => {
    dispatch({ type: 'SET_FIELD', field, value })
    validateField(field, value)
  }
}

The benefits I've seen

This approach has solved the core problems I was hitting with useState:

  • Predictable state updates -- all form state changes go through the reducer, making behavior easier to trace
  • Centralized validation -- validation logic lives in one place instead of scattered across effects
  • Better testing -- I can unit test the reducer and validation schemas independently
  • Cleaner async handling -- async validation becomes explicit actions rather than racing effects

The mental model is simpler too. Instead of thinking about individual state variables and their relationships, I think about the form as a state machine that responds to user actions.

When I still use useState for forms

I'm not dogmatic about this. For simple forms with basic validation, useState is still fine:

  • Single field forms like search boxes or email signups
  • Static validation that does not depend on other fields
  • No async validation requirements

But the moment I need dependent fields, multi-step flows, or complex validation rules, I reach for useReducer first.

The key insight is that complex forms are not just collections of independent fields -- they're state machines with validation rules, dependencies, and side effects. useState optimizes for independent state, but forms need coordinated state management. Recognizing that distinction early saves hours of debugging tangled validation logic later.