When useState gets crowded: useReducer

March 4, 2022 · 5 min read

My login form ended up with five useState calls, and I could feel it getting messy before I could say why. useReducer was the fix, but only once I saw what exactly was wrong.

Five states, but really two things

const [enteredEmail, setEnteredEmail] = useState("");
const [emailIsValid, setEmailIsValid] = useState();
const [enteredPassword, setEnteredPassword] = useState("");
const [passwordIsValid, setPasswordIsValid] = useState();
const [formIsValid, setFormIsValid] = useState(false);

Look at it again and there are two inputs, each with a value and a validity. Those pairs belong together. I was managing them as four unrelated variables.

The function form doesn't save me here

The blur handlers looked like this:

const validateEmailHandler = () => {
  setEmailIsValid(enteredEmail.includes("@"));
};

I'd just learned to use the function form when new state depends on old state. But that only works for the same state. This one reads enteredEmail to set emailIsValid, so there's no previous snapshot to pass in. In rare cases React hasn't applied the latest enteredEmail yet, and I validate a stale value. Merging the two into one state removes the problem, because the update then depends on its own previous state.

How useReducer works

It returns a pair, like useState: the current state and a function. The function is called dispatch, and you don't hand it a new value. You hand it an action, a small object saying what happened. React then calls your reducer with the latest state and that action, and whatever the reducer returns becomes the new state.

const [state, dispatch] = useReducer(reducer, initialState);

// reducer: (prevState, action) => newState
event handleronChange, onBlurdispatch{ type, value }reducer(prevState, action)returnsnew state{ value, isValid }component runs againnew handlers and JSX, user acts again
The handler only describes what happened. The reducer decides what the new state is.

The login form with it

One reducer per input. The value and its validity are always set in the same place, so they can't drift apart:

function emailReducer(state, action) {
  if (action.type === "USER_INPUT") {
    return { value: action.value, isValid: action.value.includes("@") };
  }
  if (action.type === "ON_BLUR") {
    return { value: state.value, isValid: state.value.includes("@") };
  }
  return { value: "", isValid: false };
}

Inside the component, the handlers shrink to one line each, and they never see any validation logic:

const [emailState, dispatchEmail] = useReducer(emailReducer, {
  value: "",
  isValid: null,
});

const emailChangeHandler = (e) =>
  dispatchEmail({ type: "USER_INPUT", value: e.target.value });

const validateEmailHandler = () => dispatchEmail({ type: "ON_BLUR" });

The reducer lives outside the component. It doesn't need anything from inside, because React passes it the state and the action. Defining it outside also means it isn't recreated on every render.

Pull out the boolean before using it in an effect

The debounced form check from the effects note now depends on validity. If I put emailState in the dependency list, the effect re-runs on every keystroke, since the object changes whenever the value does. So I destructure first:

const { isValid: emailIsValid } = emailState;
const { isValid: passwordIsValid } = passwordState;

useEffect(() => {
  const timer = setTimeout(() => {
    setFormIsValid(emailIsValid && passwordIsValid);
  }, 500);
  return () => clearTimeout(timer);
}, [emailIsValid, passwordIsValid]);

Now it only runs when a validity flag actually flips, not for every character typed.

It isn't automatically better than useState. There's more setup, and for a single input I'd still reach for useState.

What stuck with me: when pieces of state change together, give them one owner, and let handlers say what happened instead of what the state should be.