Carl Rippon

Building SPAs

Carl Rippon
BlogBooksAbout
This site uses cookies. Click here to find out more

Managing State in Functional React Components with useReducer

December 10, 2018
reacttypescript

In the last post we created a Sign Up form in a function-based component using the useState function hook in React.

Successful sign up

There was fair bit of state to manage though and as the user interaction requirements for our sign up form expand, the state that needs to be managed could become even more complex.

In this post, we are going to refactor our Sign Up form and leverage the useReducer React function to hopefully improve the state management. This will feel a lot like managing state with Redux. Our project will still use TypeScript, so, our actions and reducer will be strongly-typed.

Getting started

We’ll start from the project we created in the last post. We can download this from GitHub and enter the npm install command in the Terminal to get us to the correct starting point. Note that at the time of writing, useReducer isn’t released yet. So, we are using the alpha version of React.

Changing state interface

We are going to manage our state all together in a single object. So, let’s add an interface for this just below the IProps interface:

interface IState {
  firstName: string;
  firstNameError: string;
  emailAddress: string;
  emailAddressError: string;
  submitted: boolean;
  submitResult: ISignUpResult;
}

So, we have state for the first name, email address, the validation errors, whether the Sign Up form has been submitted and the result of the submission. These were stored as separate variables in the last post but in this post we’re storing them as a single object.

Let’s also create an object to hold the default values of the state just below this interface:

const defaultState = {
  firstName: "",
  firstNameError: "",
  emailAddress: "",
  emailAddressError: "",
  submitted: false,
  submitResult: {
    success: false,
    message: ""
  }
};

Creating action interfaces

As in the Redux pattern, an action is invoked in order to make a change to state. We have 3 actions that happen in our Sign Up form:

  • A change to the first name. The validation of the first name also happens during this action.
  • A change to the email address. The validation of the email address also happens during this action.
  • The form submission.

We are in TypeScript land, so, let’s create interfaces for these actions to ensure our code is type-safe:

interface IFirstNameChange {
  type: "FIRSTNAME_CHANGE";
  value: string;
}
interface IEmailAddressChange {
  type: "EMAILADDRESS_CHANGE";
  value: string;
}
interface ISubmit {
  type: "SUBMIT";
  firstName: string;
  emailAddress: string;
}

We’ll also create a union type of these action types that will come in handy later when we create the reducer:

type Actions = IFirstNameChange | IEmailAddressChange | ISubmit;

Creating the reducer

We’re going to start by importing the useReducer function rather than useState at the top of SignUp.tsx:

import React, { FC, ChangeEvent, FormEvent, useReducer } from "react";

The lines of code where we declared the state variables using useState in the last post can be removed. We’ll replace these with the reducer function, so, let’s make a start on this:

const [state, dispatch] = useReducer((state: IState, action: Actions) => {
  // TODO - create and return the new state for the given action
}, defaultState);

We’ve used the useReducer function from React to create a reducer in our component. We need to pass our reducer for our component into useReducer, so, we have done this directly inside it as its first parameter as an arrow function. We need to pass the default state as the second parameter to useReducer. So, we have passed the defaultState object we created earlier on as the second parameter.

Our reducer function takes in the current state along with the action. Notice how we’ve used the Actions union type for the action argument which will help us prevent mistakes when we reference the action argument. Our job now is to create and return the new state for the action. Let’s make a start on this:

const [state, dispatch] = useReducer((state: IState, action: Actions) => {
  switch (action.type) {    case "FIRSTNAME_CHANGE":    // TODO - validate first name and create new state firstName and firstNameError    case "EMAILADDRESS_CHANGE":    // TODO - validate email address and create new state emailAddress and emailAddressError    case "SUBMIT":    // TODO - validate first name and email address    // TODO - call onSignUp prop    // TODO - create new state    default:      return state;  }}, defaultState);

So, we’re using a switch statement to branch the logic for each of the three action types. Let’s start with the logic for when the first name changes:

case "FIRSTNAME_CHANGE":
  return {    ...state,    firstName: action.value,    firstNameError: validateFirstName(action.value)  };

We create and return the new state object by spreading the current state and overwriting the new first name and new first name validation error. We also call the first name validator as part of this statement.

Notice how the clever TypeScript compiler knows that the action argument variable has a value prop because we are branched inside the FIRSTNAME_CHANGE action.

Let’s follow a similar pattern for the EMAILADDRESS_CHANGE action:

case "EMAILADDRESS_CHANGE":
  return {
    ...state,
    emailAddress: action.value,
    emailAddressError: validateEmailAddress(action.value)
  };

The final branch of logic we need to implement is for the sign up submission. Let’s make a start on this by validating the first name and email address:

case "SUBMIT":
  const firstNameError = validateFirstName(action.firstName);
  const emailAddressError = validateEmailAddress(action.emailAddress);
  if (firstNameError === "" && emailAddressError === "") {
    // TODO - invoke onSignUp prop and create and return new state
  } else {
    return {
      ...state,
      firstNameError,
      emailAddressError
    };
  }

We return the new state with the new validation errors if first name or the email address are invalid.

Let’s finish the submission branch when the first name and email address are valid:

case "SUBMIT":
  const firstNameError = validateFirstName(action.firstName);
  const emailAddressError = validateEmailAddress(action.emailAddress);
  if (firstNameError === "" && emailAddressError === "") {
    const submitResult = props.onSignUp({      firstName: action.firstName,      emailAddress: action.emailAddress    });    return {      ...state,      firstNameError,      emailAddressError,      submitted: true,      submitResult    };  } else {
    return {
      ...state,
      firstNameError,
      emailAddressError
    };
  }

So, we call the onSignUp prop and return the new state with the submission result.

That’s our reducer function done. We are now nicely changing the state in a single place.

Dispatching actions

We now need to refactor all the places in our component where we reference the state change functions from useState. The useReducer returns a function called dispatch that we can now use to invoke an action that will pass through the reducer to change state.

We’ll start with the validator functions. There is no need to set the validation error state value anymore because this is done in our reducer. We can also move these functions outside our component because they have no dependency on our component anymore:

type Actions = IFirstNameChange | IEmailAddressChange | ISubmit;

const validateFirstName = (value: string): string => {  const error = value ? "" : "You must enter your first name";  return error;};const validateEmailAddress = (value: string): string => {  const error = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(    value  )    ? ""    : "You must enter a valid email address";  return error;};
const SignUp: FC<IProps> = props => { ... }

Moving on to our first name and email address change handlers. We now need to just dispatch the relevant action using dispatch:

const handleFirstNameChange = (e: ChangeEvent<HTMLInputElement>) => {
  dispatch({    type: "FIRSTNAME_CHANGE",    value: e.currentTarget.value  });};

const handleEmailAddressChange = (e: ChangeEvent<HTMLInputElement>) => {
  dispatch({    type: "EMAILADDRESS_CHANGE",    value: e.currentTarget.value  });};

Our submit handler is also simplified because we just need to dispatch the SUBMIT action:

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  dispatch({
    type: "SUBMIT",
    firstName: state.firstName,
    emailAddress: state.emailAddress
  });
};

Referencing state values

The useReducer also returns a variable called state that contains our state object that we can use to reference the current state values. So, let’s update our state references in our JSX:

<form noValidate={true} onSubmit={handleSubmit}>
  <div className="row">
    <label htmlFor="firstName">First name</label>
    <input
      id="firstName"
      value={state.firstName}      onChange={handleFirstNameChange}
    />
    <span className="error">{state.firstNameError}</span>  </div>

  <div className="row">
    <label htmlFor="emailAddress">Email address</label>
    <input
      id="emailAddress"
      value={state.emailAddress}      onChange={handleEmailAddressChange}
    />
    <span className="error">{state.emailAddressError}</span>  </div>

  <div className="row">
    <button
      type="submit"
      disabled={state.submitted && state.submitResult.success}    >
      Sign Up
    </button>
  </div>

  {state.submitted && (    <div className="row">
      <span
        className={
          state.submitResult.success ? "submit-success" : "submit-failure"        }
      >
        {state.submitResult.message}      </span>
    </div>
  )}
</form>

So, that’s our component refactored. Let’s start our app in our development server and give this try:

npm start

If we hit the Sign Up button without filling in the form, we correctly get the validation errors rendered:

Validation errors

If we properly fill out the form and hit the Sign Up button, we get confirmation that the form has been submitted okay:

Successful sign up

Wrap up

The benefit of this approach is that the logic for the changing of state is located together which arguably makes it easier to understand what is going off. In our example, we naturally extracted the validator functions to be outside of the SignUp component, making them reusable in other components.

This is more code though - 108 lines v 78 lines. The TypeScript types for the actions do bloat the code a bit but they do ensure our reducer function is strongly-typed which is nice.

If there was more state with more complex interactions with perhaps asynchronous actions then perhaps we’d gain more benefit of using useReducer over useState.

The code in this post is available in GitHub at https://github.com/carlrip/ReactFunctionComponentState/tree/master/useReducer


Want to learn more about React and TypeScript? Check out my book
Learn React with TypeScript 3