Managing State in Functional React Components with useReducer
In the last post we created a Sign Up form in a function-based component using the useState
function hook in React.
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:
If we properly fill out the form and hit the Sign Up button, we get confirmation that the form has been submitted okay:
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
If you to learn more about using TypeScript with React, you may find my course useful: