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

If you've already [read the theory](https://yashrajxdev.blog/usereducer-in-react-a-simple-counter-then-the-bigger-picture), 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`:

```javascript
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.

```javascript
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.

```javascript
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.

```js
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.

```js
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.

```js
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 *<mark class="bg-yellow-200 dark:bg-yellow-500/30">can't</mark>* <mark class="bg-yellow-200 dark:bg-yellow-500/30"> be calculated</mark> — 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.

```jsx
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.

![](https://cdn.hashnode.com/uploads/covers/6942d60438f3c2af46f04eed/4ef0dd29-633f-4cc1-a7b9-d4a4320c576b.png align="center")

* * *

## 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:

```js
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.
