React  

You’re Using useState for That? Cute

So Why Are We Talking About useReducer?

Let’s be honest. useState It's cool… until it isn’t.

Simple counter? Sure. But forms with 5+ fields, validation, resetting, and conditional rendering? Suddenly, your component has 12 useState calls, updates flying around, and you’re juggling spaghetti.

That’s where useReducer comes in. It gives you structure, type safety, and a clear flow of how your component updates. Like building a tiny Redux, but built-in and lightweight.

Wanna learn about useState? Here is a detailed article: useState in detail

Use cases are simple

  • You’re managing complex state objects (like form inputs or nested structures)

  • State changes are based on actions, not direct setters

  • You want a predictable state transition flow

  • You’re tired of writing setForm({...form, name: e.target.value })

The useReducer Flow

Here’s the pattern every useReducer setup follows:

  1. Define your state type

  2. Define your action types

  3. Write the reducer function

  4. Initialize the state

  5. Call useReducer(reducer, initialState) in the component

  6. Dispatch actions to update state

Let’s break it with a super basic counter.

Counter Example. Understanding the Full Flow

1. State and Action Types

export type CounterState = {
    count: number;
}

export type CounterAction = { type: 'increment' } | { type: 'decrement' };

State has a count: number

Actions can be either 'increment' or 'decrement'

2. Reducer Function

Here's a tiny state machine for counting. You send in an action like 'increment', and it returns the new count (new state).

import type { CounterState, CounterAction } from "./types";

export const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
    switch (action.type) {
        case 'increment':
            return { count: state.count + 1 };
        case 'decrement':
            return { count: state.count - 1 };
        default:
            return state;
    }
};

Breakdown of code

Defining a reducer: a function that takes the current state and an action, and returns the new state.

export const counterReducer = (
  state: CounterState,
  action: CounterAction
): CounterState => {

Depending on the action.type You either increase or decrease the count by 1.

switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };

If it gets an action it doesn’t recognize, it just returns the existing state.

default:
      return state;

3. Component

import React, { useReducer } from "react";
import { counterReducer } from "../reducer";
import { type CounterState } from "../types";

export default function Counter() {
    const initialState: CounterState = { count: 0 };

    const [state, dispatch] = useReducer(counterReducer, initialState);
    return (
        <div>
            <h2>{state.count}</h2>
            <button onClick={() => dispatch({ type: 'increment' })}> + </button>
            <button onClick={() => dispatch({ type: 'decrement' })}> - </button>
        </div>
    );
}

Breakdown of code

const initialState: CounterState = { count: 0 };

Initialize the state, this is the starting point: count Begins at 0.

const [state, dispatch] = useReducer(counterReducer, initialState);
  • state is whatever the reducer returns.

  • dispatch is how you yell at the reducer to change state.

  • Under the hood, React wires counterReducer to your component’s lifecycle.

return (
    <div>
      <h2>{state.count}</h2>
      <button onClick={() => dispatch({ type: 'increment' })}> + </button>
      <button onClick={() => dispatch({ type: 'decrement' })}> - </button>
    </div>
  );
}
  • UI shows the current count.

  • + button fires dispatch({ type: 'increment' }) reducer bumps the count.

  • - button fires dispatch({ type: 'decrement' }) reducer drops the count.

  • React re-renders automatically with the new state, no extra hooks, no manual updates.

 

Pretty nice! Now let's see the real life use case.

Now the Real Use Case: Managing a Complex Form

Let’s take it up a notch. A real-world form with multiple inputs, validation, errors, and reset.

Let's back to our framework:

1. State and Action Types

export type FormState = {
    name: string;
    email: string;
    age: string;
    errors: {
        name: string;
        email: string;
        age: string;
    };
};

type UpdateForm = {
    type: 'update';
    field: string;
    value: string;
}

type SetFormError = {
    type: 'setError';
    errors: FormState['errors'];
}

type FormRest = {
    type: 'reset';
}

export type FormAction = UpdateForm | SetFormError | FormRest

Breakdown of code

export type FormState = {
    name: string;
    email: string;
    age: string;
    errors: {
        name: string;
        email: string;
        age: string;
    };
};

Here we are defining what the state should look like inside the form.

  • name, email, age: regular input fields (all string)

  • errors: a nested object tracking validation errors for each field
    This way, the state holds both data and its validation result.

Pro tip: Keeping errors inside state is avoids extra hooks and keeps it local.

Now the actions:

// "Yo reducer, update this field with this value.
type UpdateForm = {
    type: 'update';
    field: string;
    value: string;
};

//Update the errors object with a new one, probably after a validation run.
type SetFormError = {
    type: 'setError';
    errors: FormState['errors']; 
    // this syntax means, the errors property in this action should match exactly the errors type inside FormState
};

// This just resets the whole form back to its initial state.
type FormReset = {
    type: 'reset';
};

Union:

export type FormAction = UpdateForm | SetFormError | FormReset;

Step 2: Reducer Function

import type { FormState, FormAction } from "./types";
export const intialFormState: FormState = {
    name: '',
    email: '',
    age: '',
    errors: {
        name: "",
        email: "",
        age: ""
    }
};

export const formReducer = (state: FormState, action: FormAction): FormState => {
    switch (action.type) {
        case 'update':
            return {
                ...state,
                [action.field]: action.value,
                errors: { ...state.errors, [action.field] : undefined }
            };

        case "reset":
            return intialFormState;
        case "setError":
            return {
                ...state,
                errors: action.errors
            }
         default:
            return state;
    }
}

Breakdown of code

export const initialFormState: FormState = {
    name: '',
    email: '',
    age: '',
    errors: {
        name: "",
        email: "",
        age: ""
    }
};

The Initial State: We feed into useReducer as the initial value.

export const formReducer = (state: FormState, action: FormAction): FormState => {

The Reducer Function: It gets the current state and a FormAction, and it returns the new state.

Cases

Case: 'update'

case 'update':
    return {
        ...state,
        [action.field]: action.value,
        errors: { 
                ...state.errors, 
                [action.field] : undefined 
        }
    };

/* 
...state
Start with what I already have

[action.field]: action.value

- action.field is a string, like 'name', 'email', or 'age'
- action.value is whatever the user typed

errors: {
    ...state.errors,
    [action.field]: undefined
}

...state.errors: keeps all current error messages as-is
[action.field]: undefined: clears the error for the field the user is editing

Because if the user starts changing a field, you probably want to remove the old error until you revalidate.
*/

Case: 'reset'

case 'reset':
    return initialFormState;

Resets the entire form state back to the initial empty values.

Case: 'setError'

case 'setError':
    return {
        ...state,
        errors: action.errors
    }

Drops in new error messages, usually after validating the form on submit. This is where the magic happens when you say: “Hey, that email is garbage” or “Age must be a number.”

Step 3: Hook It All Up in the Component

// Form.tsx
import { useReducer } from "react";
import { formReducer, intialFormState } from "../reducer";

export default function Form() {
  const [state, dispatch] = useReducer(formReducer, intialFormState);

  const validate = () => {
    const errors: typeof state.errors = {
      name: '',
      email: '',
      age: ''
    };
    if (!state.name.trim()) errors.name = 'Name is required';
    if (!state.email.includes('@')) errors.email = 'Invalid email';
    if (!/^\d+$/.test(state.age)) errors.age = 'Age must be a number';
    return errors;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const errors = validate();
    if (Object.values(errors).some(err => err)) {
      dispatch({ type: 'setError', errors });
    } else {
      alert(`Submitted: ${JSON.stringify(state, null, 2)}`);
      dispatch({ type: 'reset' });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name:</label>
        <input
          value={state.name}
          onChange={(e) =>
            dispatch({ type: 'update', field: 'name', value: e.target.value })
          }
        />
        {state.errors.name && <span style={{ color: 'red' }}>{state.errors.name}</span>}
      </div>

      <div>
        <label>Email:</label>
        <input
          value={state.email}
          onChange={(e) =>
            dispatch({ type: 'update', field: 'email', value: e.target.value })
          }
        />
        {state.errors.email && <span style={{ color: 'red' }}>{state.errors.email}</span>}
      </div>

      <div>
        <label>Age:</label>
        <input
          value={state.age}
          onChange={(e) =>
            dispatch({ type: 'update', field: 'age', value: e.target.value })
          }
        />
        {state.errors.age && <span style={{ color: 'red' }}>{state.errors.age}</span>}
      </div>

      <button type="submit">Submit</button>
      <button type="button" onClick={() => dispatch({ type: 'reset' })}>
        Reset
      </button>
    </form>
  );
}

Breakdown of code

1. useReducer(formReducer, intialFormState)

const [state, dispatch] = useReducer(...)


/* 
Instead of doing:
   const [name, setName] = useState('');
   const [email, setEmail] = useState('');

   useReducer(formReducer, intialFormState)

   Replaces multiple useState() calls.

   state = the full form state.
   dispatch = how you update it.
*/

2. validate() function: The Rule Checker

const validate = () => {
  const errors: typeof state.errors = {
    name: '',
    email: '',
    age: ''
  };

  if (!state.name.trim()) errors.name = 'Name is required'; //If name is blank > error.
  if (!state.email.includes('@')) errors.email = 'Invalid email'; //If email doesn't have @ > error.
  if (!/^\d+$/.test(state.age)) errors.age = 'Age must be a number'; //If age isn't all digits > error.

  return errors;
};

/*
Returns something like:
{
  name: "Name is required",
  email: "",
  age: ""
}

If everything's good? Returns empty strings.
*/

What’s typeof state.errors?

"Hey, this errors object should have the exact same shape as state.errors."

Which means: { name: string, email: string, age: string }

3. handleSubmit(): When the user clicks Submit

const handleSubmit = (e: React.FormEvent) => {

  e.preventDefault();
  const errors = validate();

  if (Object.values(errors).some(err => err)) {
    dispatch({ type: 'setError', errors });
  } 
  else {
    alert(`Submitted: ${JSON.stringify(state, null, 2)}`);
    dispatch({ type: 'reset' });
  }
};
  • e.preventDefault(): stops the form from refreshing the page.

  • validate(): runs checks and gets error messages.

  • Object.values(errors).some(err => err): checks if any error message exists.

    • If yes: send errors to state using dispatch({ type: 'setError', errors })

    • If not: alert the data and reset the form with dispatch({ type: 'reset' })

  • What is dispatch({ type: '...' })?

    • This is how we "talk" to the reducer and say: “Yo reducer, update the state based on this action.”

    • You don’t call setState() manually, reducer handles everything.

4. The Form UI

<input
  value={state.name}
  onChange={(e) =>
    dispatch({ type: 'update', field: 'name', value: e.target.value })
  }
/>
{state.errors.name && <span>{state.errors.name}</span>}
  • value comes from state.name

  • onChange fires dispatch() to update the reducer

  • Shows error if it exists

The same pattern is repeated for email and age.

5. Buttons

<button type="submit">Submit</button>

When clicked, triggers onSubmit={handleSubmit} on the form. It runs validation and either submits or sets errors.

Reset button

<button type="button" onClick={() => dispatch({ type: 'reset' })}>
  Reset
</button>

This clears everything back to how it started, using the 'reset' action in the reducer.

Final Structure Recap

 Inputs ➝  dispatch ➝  reducer ➝  state ➝  validate ➝  errors/output

Here’s how the flow works:

  1. You write stuff in the input, dispatch sends update to reducer

  2. You submit, validate() checks the values

  3. If errors exist, they’re shown below the inputs

  4. If not, form is submitted and reset

Best Practices With useReducer + TS

  • Always type your state and action: Don’t rely on any. Let TS be your guard.
  • Group actions smartly: No need for 10 different action types if you can generalize (like the update action in the form).
  • Split reducer and types: into separate files in real apps. Keep the code maintainable.
  • Use dispatch smartly: Don’t call it inside loops or conditionals without reason.

Don’t try to handle side effects inside reducers. Keep them pure.

Don’t use useReducer if you only have 1-2 state values. It’s not always the better tool, use it when you need structure.

Conclusion

If useState is a sharp knife, useReducer is your full toolbox. Once you learn the pattern, state, action, reducer, and dispatch, it clicks.

Don’t wait to use this only in “big apps”. Use it in your next form, or even a multi-step wizard, and you’ll see why it’s a game-changer.