DEV Community

Damien Sedgwick
Damien Sedgwick

Posted on

useReducer > useState

Today I want to talk a little bit about useReducer and how it can be used as a fantastic way to manage complex state requirements without reaching for a library or peppering your codebase with multiple useState calls.

In the following example, we are going to look at a users profile card which in the real world, would allow a user to update their first name, last name, email and phone.

We are also going to do this with TypeScript so that we get that sweet sweet type safety that it brings to the table.

All of the code for this example will be available here.

Lets crack on.

To use useReducer we must first call it and pass it a reducer function, and an initial state object:

const [state, dispatch] = useReducer(handleFormInputChange, {
  firstName: "",
  lastName: "",
  email: "",
  phone: "",
  submitted: false
});
Enter fullscreen mode Exit fullscreen mode

The state here is pretty self-explanatory, but what is dispatch and what does handleFormInputChange actually handle?

If we take a look at the latter, handleFormInputChange is our reducer function that takes in the current state and an action.

Lets take a look at the code:

export type Data = {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  submitted: boolean;
}

export type Action =
  { type: "UPDATE_FIRST_NAME", value: string }
  | { type: "UPDATE_LAST_NAME", value: string }
  | { type: "UPDATE_EMAIL", value: string }
  | { type: "UPDATE_PHONE", value: string }
  | { type: "RESET_FORM" }
  | { type: "SUBMIT_FORM" }

export const handleFormInputChange = (state: Data, action: Action) => {
  switch (action.type) {
    case "UPDATE_FIRST_NAME":
      return { ...state, firstName: action.value, submitted: false };
    case "UPDATE_LAST_NAME":
      return { ...state, lastName: action.value, submitted: false };
    case "UPDATE_EMAIL":
      return { ...state, email: action.value, submitted: false };
    case "UPDATE_PHONE":
      return { ...state, phone: action.value, submitted: false };
    case "SUBMIT_FORM":
      return { ...state, submitted: true };
    case "RESET_FORM":
      return { firstName: "", lastName: "", email: "", phone: "", submitted: false };
    default:
      throw new Error("Unhandled form input action");
  }
};
Enter fullscreen mode Exit fullscreen mode

So our state takes the shape of Data which is the first part we have declared here, the second part is our Action type which will help us when it comes to dispatching events a little later.

Lastly, we have declared our function, this is simply a switch statement that checks the action type, and handles it accordingly. If we were to send an action that we had not declared, it would throw a new error.

So for example, if we send the action type UPDATE_FIRST_NAME, the function would return all state that has not been modified, as well as the new state for the input element that has been modified, like so:

...
case "UPDATE_FIRST_NAME":
    return { ...state, firstName: action.value, submitted: false };
...
Enter fullscreen mode Exit fullscreen mode

Now that we have looked at our reducer function, lets look at dispatch.

dispatch allows us to use our reducer function to send events and values. In the case of our first name input field, it looks like this:

<Input placeholder="First Name" value={state.firstName} onChange={(e) => dispatch({ type: "UPDATE_FIRST_NAME", value: e.target.value })} />
Enter fullscreen mode Exit fullscreen mode

So as we can see, whenever onChange is called for this input element, we send the action type and the value using dispatch, and our reducer function handles updating the correct piece of state.

If we take a step back and look at our users profile component, it lets us see the whole picture for the component.

<ProfileCard>
  <UserDetailsForm>
    <Avatar src={avatar} alt="the users avatar" />
    <FormGroup>
      <Input placeholder="First Name" value={state.firstName} onChange={(e) => dispatch({ type: "UPDATE_FIRST_NAME", value: e.target.value })} />
      <Input placeholder="Last Name" value={state.lastName} onChange={(e) => dispatch({ type: "UPDATE_LAST_NAME", value: e.target.value })} />
      <Input placeholder="Email" value={state.email} onChange={(e) => dispatch({ type: "UPDATE_EMAIL", value: e.target.value })} />
      <Input placeholder="Phone" value={state.phone} onChange={(e) => dispatch({ type: "UPDATE_PHONE", value: e.target.value })} />
    </FormGroup>
    <Button type="button" onClick={() => state.submitted ? dispatch({ type: "RESET_FORM" }) : dispatch({ type: "SUBMIT_FORM" })}>{state.submitted ? "RESET" : "SAVE"}</Button>
  </UserDetailsForm>
</ProfileCard>
Enter fullscreen mode Exit fullscreen mode

As you can see, all of the onChange handlers and the onClick handler are very simple. All we are doing is using dispatch to send an action type and a potential value should one be required.

No more tracking multiple input fields with useState and declaring different call handlers for different elements.

The entire form can be handled using one function and one state object.

I also mentioned the use of TypeScript here, but I didn't really explain why it was beneficial.

The reason it is beneficial in this example, is that we would get an error should we try and pass a value along with an event, that did not accept a value.

So if we tried to do:

onClick={() => dispatch({type: SUBMIT_FORM, value: "some value"})
Enter fullscreen mode Exit fullscreen mode

It would error because the action SUBMIT_FORM is not expecting any other value to be sent with the event.

You can visit the demo using this link useRef demo

For further reading, you can check out how useState and useReducer` behave differently here: useState vs useReducer

Top comments (0)