Skip to main content

Command Palette

Search for a command to run...

Taming Form State with useReducer: From Messy to Production-Ready

Updated
9 min read
Taming Form State with useReducer: From Messy to Production-Ready

If you've already read the theory, you know that useReducer shines when state transitions are complex and interdependent. Forms are one of the best real-world cases for it. Let's build something that actually holds up in production.


The Problem with useState in Complex Forms

Before writing a single line of useReducer, here's what a typical multi-field form looks like with useState:

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null);

Six separate state variables that all need to move together — especially during submission. When isSubmitting is true, you want to clear errors, lock fields, and show a spinner. You're now coordinating six set* calls scattered across your handlers. This is where bugs live.

useReducer centralises all of that into a single, predictable state machine.


The State Shape

Design the state first, before writing any reducer logic. This single decision defines everything else.

const initialState = {
  values: {
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
  },
  errors: {
    name: null,
    email: null,
    password: null,
    confirmPassword: null,
  },
  touched: {
    name: false,
    email: false,
    password: false,
    confirmPassword: false,
  },
  isSubmitting: false,
  submitStatus: null, // 'success' | 'error' | null
};

Three sub-objects matter here: values holds raw input, errors holds validation messages (or null when clean), and touched tracks whether the user has interacted with a field. Showing errors only on touched fields is the behaviour users actually expect.


The Action Types

Keep these as constants. String literals scattered across a codebase are a maintenance hazard.

const ACTIONS = {
  SET_FIELD: 'SET_FIELD',
  SET_TOUCHED: 'SET_TOUCHED',
  SET_ERROR: 'SET_ERROR',
  SET_ERRORS: 'SET_ERRORS',
  SUBMIT_START: 'SUBMIT_START',
  SUBMIT_SUCCESS: 'SUBMIT_SUCCESS',
  SUBMIT_FAILURE: 'SUBMIT_FAILURE',
  RESET: 'RESET',
};

The Reducer

This is the core. Every state transition lives here, nowhere else.

function formReducer(state, action) {
  switch (action.type) {
    case ACTIONS.SET_FIELD:
      return {
        ...state,
        values: {
          ...state.values,
          [action.field]: action.value,
        },
        // Clear the error as soon as the user starts correcting
        errors: {
          ...state.errors,
          [action.field]: null,
        },
      };

    case ACTIONS.SET_TOUCHED:
      return {
        ...state,
        touched: {
          ...state.touched,
          [action.field]: true,
        },
      };

    case ACTIONS.SET_ERROR:
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.field]: action.message,
        },
      };

    case ACTIONS.SET_ERRORS:
      return {
        ...state,
        errors: {
          ...state.errors,
          ...action.errors,
        },
      };

    case ACTIONS.SUBMIT_START:
      return {
        ...state,
        isSubmitting: true,
        submitStatus: null,
        errors: Object.fromEntries(
          Object.keys(state.errors).map((k) => [k, null])
        ),
      };

    case ACTIONS.SUBMIT_SUCCESS:
      return {
        ...initialState,
        submitStatus: 'success',
      };

    case ACTIONS.SUBMIT_FAILURE:
      return {
        ...state,
        isSubmitting: false,
        submitStatus: 'error',
        errors: action.errors || state.errors,
      };

    case ACTIONS.RESET:
      return initialState;

    default:
      return state;
  }
}

Notice what SUBMIT_START does: it clears all errors in one shot and sets isSubmitting atomically. With useState, this is four separate calls. Here it's one dispatch, one render cycle. The SUBMIT_SUCCESS case resets to initialState but preserves submitStatus: 'success' so you can show a confirmation message.


The Validation Layer

Keep validation completely separate from state logic. It's a pure function — same input, same output, no side effects.

function validate(values) {
  const errors = {};

  if (!values.name.trim()) {
    errors.name = 'Name is required.';
  } else if (values.name.trim().length < 2) {
    errors.name = 'Name must be at least 2 characters.';
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!values.email) {
    errors.email = 'Email is required.';
  } else if (!emailRegex.test(values.email)) {
    errors.email = 'Please enter a valid email address.';
  }

  if (!values.password) {
    errors.password = 'Password is required.';
  } else if (values.password.length < 8) {
    errors.password = 'Password must be at least 8 characters.';
  } else if (!/[A-Z]/.test(values.password)) {
    errors.password = 'Password must contain at least one uppercase letter.';
  }

  if (!values.confirmPassword) {
    errors.confirmPassword = 'Please confirm your password.';
  } else if (values.password !== values.confirmPassword) {
    errors.confirmPassword = 'Passwords do not match.';
  }

  return errors; // Empty object = no errors
}

The Custom Hook

Wrap all the dispatch logic into a custom hook. Your component should never know what dispatch is — it should only see named, intention-revealing handlers.

import { useReducer, useCallback } from 'react';

function useSignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = useCallback((field, value) => {
    dispatch({ type: ACTIONS.SET_FIELD, field, value });
  }, []);

  const handleBlur = useCallback(
    (field) => {
      dispatch({ type: ACTIONS.SET_TOUCHED, field });

      // Validate the single field on blur
      const errors = validate(state.values);
      if (errors[field]) {
        dispatch({
          type: ACTIONS.SET_ERROR,
          field,
          message: errors[field],
        });
      }
    },
    [state.values]
  );

  const handleSubmit = useCallback(
    async (e) => {
      e.preventDefault();

      const errors = validate(state.values);
      const hasErrors = Object.keys(errors).length > 0;

      if (hasErrors) {
        // Mark all fields as touched so every error becomes visible
        dispatch({ type: ACTIONS.SET_ERRORS, errors });
        Object.keys(state.values).forEach((field) => {
          dispatch({ type: ACTIONS.SET_TOUCHED, field });
        });
        return;
      }

      dispatch({ type: ACTIONS.SUBMIT_START });

      try {
        // Replace this with your actual API call
        await fakeApiCall(state.values);
        dispatch({ type: ACTIONS.SUBMIT_SUCCESS });
      } catch (apiError) {
        dispatch({
          type: ACTIONS.SUBMIT_FAILURE,
          errors: apiError.fieldErrors || {},
        });
      }
    },
    [state.values]
  );

  const handleReset = useCallback(() => {
    dispatch({ type: ACTIONS.RESET });
  }, []);

  // Derived values — computed, not stored in state
  const isFormDirty = Object.values(state.values).some((v) => v !== '');
  const hasVisibleErrors = Object.entries(state.errors).some(
    ([field, msg]) => msg && state.touched[field]
  );

  return {
    values: state.values,
    errors: state.errors,
    touched: state.touched,
    isSubmitting: state.isSubmitting,
    submitStatus: state.submitStatus,
    isFormDirty,
    hasVisibleErrors,
    handleChange,
    handleBlur,
    handleSubmit,
    handleReset,
  };
}

isFormDirty and hasVisibleErrors are derived values — computed on every render from existing state. Never put derived values into state itself; that's how you get stale data. The only things that belong in state are values that can't be calculated — raw user input, server responses, async flags like isSubmitting. Everything else is fair game to derive.


The Component

With the hook doing all the heavy lifting, the component becomes almost entirely declarative.

function FieldError({ message, touched }) {
  if (!touched || !message) return null;
  return (
    <span role="alert" style={{ color: 'var(--color-text-danger)', fontSize: 13 }}>
      {message}
    </span>
  );
}

export default function SignupForm() {
  const {
    values,
    errors,
    touched,
    isSubmitting,
    submitStatus,
    isFormDirty,
    handleChange,
    handleBlur,
    handleSubmit,
    handleReset,
  } = useSignupForm();

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Full name</label>
        <input
          id="name"
          type="text"
          value={values.name}
          onChange={(e) => handleChange('name', e.target.value)}
          onBlur={() => handleBlur('name')}
          disabled={isSubmitting}
          aria-invalid={touched.name && !!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        <FieldError message={errors.name} touched={touched.name} />
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={values.email}
          onChange={(e) => handleChange('email', e.target.value)}
          onBlur={() => handleBlur('email')}
          disabled={isSubmitting}
          aria-invalid={touched.email && !!errors.email}
        />
        <FieldError message={errors.email} touched={touched.email} />
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={values.password}
          onChange={(e) => handleChange('password', e.target.value)}
          onBlur={() => handleBlur('password')}
          disabled={isSubmitting}
          aria-invalid={touched.password && !!errors.password}
        />
        <FieldError message={errors.password} touched={touched.password} />
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm password</label>
        <input
          id="confirmPassword"
          type="password"
          value={values.confirmPassword}
          onChange={(e) => handleChange('confirmPassword', e.target.value)}
          onBlur={() => handleBlur('confirmPassword')}
          disabled={isSubmitting}
          aria-invalid={touched.confirmPassword && !!errors.confirmPassword}
        />
        <FieldError message={errors.confirmPassword} touched={touched.confirmPassword} />
      </div>

      {submitStatus === 'success' && (
        <p role="status" style={{ color: 'var(--color-text-success)' }}>
          Account created successfully!
        </p>
      )}

      {submitStatus === 'error' && (
        <p role="alert" style={{ color: 'var(--color-text-danger)' }}>
          Something went wrong. Please try again.
        </p>
      )}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating account…' : 'Create account'}
      </button>

      {isFormDirty && (
        <button type="button" onClick={handleReset} disabled={isSubmitting}>
          Clear form
        </button>
      )}
    </form>
  );
}

The component contains zero business logic. It maps state to UI, and UI events to handlers. That's its entire job.


The State Machine, Visualised

Here's how the form moves through its lifecycle:Every box here corresponds to a distinct shape of your state object. The reducer enforces that you can only move along the valid arrows — there's no code path that accidentally leaves isSubmitting: true when an error happens.


Production Patterns Worth Noting

Why useCallback on every handler?

The custom hook returns new function references on every render by default. If you pass handleChange as a prop to a child field component wrapped in React.memo, a new reference breaks memoisation. useCallback with accurate dependency arrays prevents that.

Why clear errors on SET_FIELD?

The UX expectation is "error disappears the moment you start fixing it." If you only clear on re-blur, the user sees the error while they're typing the correction — that's friction with no payoff.

Why not store derived values in state?

isFormDirty is always computable from values. If you stored it separately, you'd need to remember to update it every time SET_FIELD fires. That's a coordination bug waiting to happen. Compute it, don't store it.

Why batch all touches on failed submit?

When a user clicks submit without touching any field, no errors are visible yet (because no field is touched). The submit handler marks all fields as touched at once, so every unfilled field suddenly shows its error. This is the standard behaviour users expect from production forms.

Why does SUBMIT_SUCCESS return { ...initialState, submitStatus: 'success' }?

If you returned initialState directly, submitStatus would be null and you'd have no way to show a success message. Spreading initialState and then overriding one key is the clean pattern.


Fake API for Testing

Here's a simple async stub you can swap out for a real call:

async function fakeApiCall(values) {
  await new Promise((res) => setTimeout(res, 1500));

  // Simulate a server-side email-taken error
  if (values.email === 'taken@example.com') {
    const err = new Error('Email already registered');
    err.fieldErrors = { email: 'This email is already in use.' };
    throw err;
  }
}

Notice err.fieldErrors — the reducer's SUBMIT_FAILURE case reads action.errors and spreads it into state.errors, so server-side field errors land in exactly the right place and show up under the right input.


What This Buys You

The architecture you now have is testable in complete isolation — you can import formReducer and validate and unit-test every transition without mounting a single component. The component itself is a thin presentation layer. The custom hook is the interface. The reducer is the contract.

When requirements change — add a username field, add async email-availability checks, add a progress stepper — you add to the state shape, add an action, and update the reducer. Nothing else needs to change.

More from this blog