How to use React useState hook like a Pro.

Joel OjerindeJoel Ojerinde
13 min read

Introduction

The useState hook is one of the many hooks shipped with React and is used for managing states in React. Another hook that performs a similar function is the useReducer hook, but they differ in how they are used.

State generally refers to properties or data that need to be tracked in an application.

This article will focus on the useState hook. If you would like to learn about the useReducer hook or how to use it like a pro, please let me know in the comments.

Some developers mistake the useContext hook for a state management tool, but this is not accurate. The useContext hook is simply a hook for providing application-wide data, and updating this data is still done with the help of either useState or useReducer.

What is a hook?

A hook is a function that allow one to hook into React state and some other React features. Some hooks such as useState, useEffect, etc., are shipped with React.

We can as well create our own custom hook and one thing to keep in mind while creating a custom hook is that the function name must start with use.

To use the useState hook, we have to import it as a named import from react as shown below.

import { useState } from "react";

The useState hook can accept an initial state data, however it is optional to pass in data.

The hook returns an array that contains two elements, the state and a function to update the state. We can use array destructuring on the returned array to access these two elements in one line, as demonstrated below.

// Array Destructuring
const [state, funtionToUpDateState] = useState(optionalInitialData);

// use case
const [firstName, setFirstName] = useState("");

In the use case above, the initial data that is stored in the firstName state is an empty string (""), the function that will be called to update this data is setFirstName, to use the data, we use firstName

Now, examine the two code snippets below. Which do you prefer as a Pro React developer? Which do you consider to be the best?

// Managing the firstName input field
const [firstName, setFirstName] = useState("");
const [email, setEmail] = useState("");
const [lastName, setLastName] = useState("");

const [firstNameIsValid, setFirstNameIsValid] = useState(null);
const [emailIsValid, setEmailIsValid] = useState(null);
const [lastNameIsValid, setLastNameIsValid] = useState(null);
const [form, setForm] = useState({
  firstName: "",
  email: "",
  lastName: "",
  emailIsValid: false,
  firstNameIsValid: false,
  lastNameIsValid: false,
});

While we all may have our own preferences, I think we can all agree that the second snippet has less code and appears more professional compared to the other one.

Let's see how they both work by building a form where we will manage some states i.e storing the data that is filled in the form input field in some container and performing some actions on them.

In our form, we will have fields for First Name, Last Name, and Email. To do this, let's create an Input component that can be reused as many times as needed, in this case just three times.

import classes from "./Input.module.css";

const Input = (props) => {
  return (
    <>
      <div className={classes.form_group}>
        <label className={classes.form_label} htmlFor={props.id}>
          {props.label}
        </label>
        <input
          className={`${classes.form_input} ${
            props.isValid === false ? classes.invalid : ""
          } `}
          type={props.type}
          placeholder={props.placeholder}
          id={props.id}
          name={props.name}
          onChange={props.onChange}
          onBlur={props.onBlur}
          value={props.value}
        />
      </div>
    </>
  );
};
export default Input;

As this article is focused on writing React code like a Pro, we can simplify our Input component using the Rest and Spread operator, as demonstrated below.

const Input = (props) => {
  // Here we packed the remaining props object properties using the JavaScript Rest operator after destructuring the props object
  const { label, id, ...others } = props;
  return (
    <>
      <div className={classes.form_group}>
        <label className={classes.form_label} htmlFor={id}>
          {label}
        </label>
        {/* Here we unpacked the others using the JavaScript Spread operator */}
        <input className={classes.form_input} {...others} />
      </div>
    </>
  );
};
export default Input;

You can as well destructure the props object in the parameter parenthesis as shown below

const Input = ({ label, id, ...others }) => {
  return <></>;
};

That is how far you can go to write clean, easy to understand, and maintainable code.

Now that we have our Input component set up already, let us go ahead and create our Form component and it is in this component we will reuse the Input component. We will also be needing a button and you can go ahead and create a Button component but I won't be doing that since I don't need to reuse it in multiple places unlike the Input component.

In addition, we will be adding some basic validity checks to truly appreciate the benefits of managing states with a single useState hook instead of multiple useState hooks.

Let's get our hand dirty.

We will start by using the Input component three times as shown below. Keep in mind that we need to pass some data to each instance of the Input component, permit me to use the word instance.

import { useState } from "react";
import classes from "./Form.module.css";
import Input from "./Input";

const Form = () => {
  // Managing the firstName input field
  const [firstName, setFirstName] = useState("");

  // This is the function that will be triggered when the change event is triggered on the input element field
  const firstNameHandler = (e) => {
    setFirstName(e.target.value);
  };

  //   This is the function to validate the entered value in the input field
  const validateFirstName = (e) => {
    setFirstNameIsValid(e.target.value.trim().length > 3);
  };

  return (
    <form className={classes.form}>
      <Input
        label="First Name"
        id="firstname"
        type="text"
        placeholder="Enter your first Name"
        name="firstname"
        onChange={firstNameHandler}
        onBLur={validateFirstName}
        value={firstName}
      />

      <button type="submit">Send</button>
    </form>
  );
};
export default Form;

You will also notice that I passed the function that will be triggered when we have the native onChange event to the Input component input element field using onChange={firstNameonChangeHandler}.

Remember that the component is a custom one and the attributes name totally depend on what you want to use but with what we did in the Input component, the packing and unpacking of the props object, we have to name the attributes exactly what the input element is expecting, names like, placeholder, type, onChange, value, onBlur, etc.

You probably have a question for me at this point and I would have asked the same question. I am sure the question you have is Why are we passing the value attribute? Are we not supposed to be getting the value from the input field?

Before I answer the question let us instantiate the input field two more times and pass the neccessary data and functions.

import { useState } from "react";
import Input from "./Input";

import classes from "./Form.module.css";
const Form = () => {
  // Managing the firstName input field
  const [firstName, setFirstName] = useState("");
  const [email, setEmail] = useState("");
  const [lastName, setLastName] = useState("");

  const [firstNameIsValid, setFirstNameIsValid] = useState(null);
  const [emailIsValid, setEmailIsValid] = useState(null);
  const [lastNameIsValid, setLastNameIsValid] = useState(null);

  // This is the function that will be triggered when the change event is triggered on the input element field, when the user statrt typing.
  const firstNameOnChangeHandler = (e) => {
    setFirstName(e.target.value);
  };
  const lastNameOnChangeHandler = (e) => {
    setLastName(e.target.value);
  };

  const emailOnChangeHandler = (e) => {
    setEmail(e.target.value);
  };

  //   This are the function to validate the entered value in the input field
  const validateFirstName = (e) => {
    setFirstNameIsValid(e.target.value.trim().length > 3);
  };
  const validateLastName = (e) => {
    setLastNameIsValid(e.target.value.trim().length > 5);
  };

  const validateEmail = (e) => {
    setEmailIsValid(e.target.value.includes("@"));
  };
  return (
    <form className={classes.form}>
      <Input
        label="First Name"
        id="firstname"
        type="text"
        placeholder="Enter your first Name"
        name="firstname"
        onChange={firstNameOnChangeHandler}
        onBLur={validateFirstName}
        value={firstName}
        isValid={firstNameIsValid}
      />
      <Input
        label="Last Name"
        id="lastname"
        type="text"
        placeholder="Enter your last name"
        name="lastname"
        onChange={lastNameOnChangeHandler}
        onBlur={validateLastName}
        value={lastName}
        isValid={lastNameIsValid}
      />
      <Input
        label="E-Mail"
        id="email"
        type="email"
        placeholder="Enter your E-mail"
        name="email"
        onChange={emailOnChangeHandler}
        onBlur={validateEmail}
        value={email}
        isValid={emailIsValid}
      />

      <button type="submit">Send</button>
    </form>
  );
};
export default Form;

If we were to reuse the Input component more than three times, it would make more sense to map through a list of data and use it to render our input fields. This is shown below. Keep in mind, it depends on your preference and the specific needs of your code.

import { useState } from "react";
import classes from "./Form.module.css";
import Input from "./Input";

const Form = () => {
  // Managing the firstName input field
  const [firstName, setFirstName] = useState("");
  const [email, setEmail] = useState("");
  const [lastName, setLastName] = useState("");

  const [firstNameIsValid, setFirstNameIsValid] = useState(null);
  const [emailIsValid, setEmailIsValid] = useState(null);
  const [lastNameIsValid, setLastNameIsValid] = useState(null);

  // This is the function that will be triggered when the change event is triggered on the input element field
  const firstNameOnChangeHandler = (e) => {
    setFirstName(e.target.value);
  };
  const lastNameOnChangeHandler = (e) => {
    setLastName(e.target.value);
  };

  const emailOnChangeHandler = (e) => {
    setEmail(e.target.value);
  };

  //   This is the function to validate the entered value in the input field
  const validateFirstName = (e) => {
    setFirstNameIsValid(e.target.value.trim().length > 3);
  };
  const validateLastName = (e) => {
    setLastNameIsValid(e.target.value.trim().length > 5);
  };

  const validateEmail = (e) => {
    setEmailIsValid(e.target.value.includes("@"));
  };

  const InputData = [
    {
      label: "First Name",
      id: "firstName",
      type: "text",
      placeholder: "Enter your first Name",
      name: "firstName",
      onChange: firstNameOnChangeHandler,
      onBlur: validateFirstName,
      value: firstName,
      isValid: firstNameIsValid,
    },

    {
      label: "Last Name",
      id: "lastName",
      type: "text",
      placeholder: "Enter your last Name",
      name: "lastName",
      onChange: lastNameOnChangeHandler,
      onBlur: validateLastName,
      value: lastName,
      isValid: lastNameIsValid,
    },
    {
      label: "Email",
      id: "email",
      type: "email",
      placeholder: "Enter your email",
      name: "email",
      onChange: emailOnChangeHandler,
      onBlur: validateEmail,
      value: email,
      isValid: emailIsValid,
    },
  ];
  return (
    <form className={classes.form}>
      {InputData.map((inputData) => (
        <Input
          label={inputData.label}
          id={inputData.id}
          type={inputData.type}
          placeholder={inputData.placeholder}
          name={inputData.name}
          onChange={inputData.onChange}
          onBlur={inputData.onBlur}
          value={inputData.value}
          isValid={inputData.isValid}
        />
      ))}

      <button type="submit">Send</button>
    </form>
  );
};
export default Form;

Either of the two works fine but the formal will be too much work when you have more than 2 or 3 instances of the Input component. Having said that, I will be working with the latter approach going forward.

As you can see, we keep calling the useState hook every time we want to manage any input field value. We also have to call the same hook to store the result of the input field validity checks. Imagine if we had 5 or more input fields to manage and needed to validate each of these input fields, our code would become too much.

This is where managing all of our states with a single useState hook comes in. We will appreciate it the most here. Before I go ahead and do that, let me answer the question you asked me earlier.

Why are we passing the value attribute?

By default, the input element is considered an uncontrolled element, meaning we lack the ability to fully control it using React - we cannot write to it or clear it. However, by adding the value and onChange attributes, it becomes a controlled element, which is a concept known as "Two-way binding" in web design.

When the user enter a value, the onChange event will be triggerred and React will takes the value inputted and store it in a state e.g firtName state by the setFirstName function that we get from useState hook. This value is then written back to the input field with the value attribute that we passed to it. The user will have no idea that it is actually React that is writing what they are seeing in the input field. Though, it is what they actually typed that React will write to the input field.

I hope you understand this explanation on how Two-way binding works and why we passed the onChange and value attributes.

Now, let us re-write the Form component using the Pro approach.

import { useState } from "react";
import classes from "./Form.module.css";
import Input from "./Input";

const Form = () => {
  // Managing the firstName input field
  const [form, setForm] = useState({
    email: "",
    firstName: "",
    lastName: "",
    emailIsValid: false,
    firstNameIsValid: false,
    lastNameIsValid: false,
  });

  // This is the function that will be triggered when the change event is triggered on the input element field

  const firstNameOnChangeHandler = (e) => {
    setForm((prev) => {
      return { ...prev, firstName: e.target.value };
    });
  };

  const lastNameOnChangeHandler = (e) => {
    setForm((prev) => {
      return { ...prev, lastName: e.target.value };
    });
  };

  const emailOnChangeHandler = (e) => {
    setForm((prev) => {
      return { ...prev, email: e.target.value };
    });
  };

  //   This is the function to validate the entered value in the input field
  const validateFirstName = (e) => {
    setForm((prev) => {
      return { ...prev, firstNameIsValid: e.target.value.trim().length > 3 };
    });
  };
  const validateLastName = (e) => {
    setForm((prev) => {
      return { ...prev, lastNameIsValid: e.target.value.trim().length > 3 };
    });
  };

  const validateEmail = (e) => {
    setForm((prev) => {
      return { ...prev, emailIsValid: e.target.value.includes("@") };
    });
  };

  const InputData = [
    {
      label: "First Name",
      id: "firstName",
      type: "text",
      placeholder: "Enter your first Name",
      name: "firstName",
      onChange: firstNameOnChangeHandler,
      onBlur: validateFirstName,
      value: form.firstName,
      isValid: form.firstNameIsValid,
    },

    {
      label: "Last Name",
      id: "lastName",
      type: "text",
      placeholder: "Enter your last Name",
      name: "lastName",
      onChange: lastNameOnChangeHandler,
      onBlur: validateLastName,
      value: form.lastName,
      isValid: form.lastNameIsValid,
    },
    {
      label: "Email",
      id: "email",
      type: "email",
      placeholder: "Enter your email",
      name: "email",
      onChange: emailOnChangeHandler,
      onBlur: validateEmail,
      value: form.email,
      isValid: form.emailIsValid,
    },
  ];
  return (
    <form className={classes.form}>
      {InputData.map((inputData) => (
        <Input
          label={inputData.label}
          id={inputData.id}
          type={inputData.type}
          placeholder={inputData.placeholder}
          name={inputData.name}
          onChange={inputData.onChange}
          onBlur={inputData.onBlur}
          value={inputData.value}
          isValid={inputData.isValid}
        />
      ))}

      <button type="submit">Send</button>
    </form>
  );
};
export default Form;

Can you see the difference in the amount of codes? Yes, that is how we significantly reduce the amount of code

Now, let see how the differences.

In the Pro approach, we passed a JavaScript object which has the keys of the state (firatName, email, Lastname, ...) we want to manage and their intial data as the values of each key. The useState hook as usual still returned an array containing two element, the state itself and the function to update the state.

So, how do we update our state? In the formal approach, we update our state by calling the returned function by the hook and pass in a new value as shown below

const [firstName, setFirstName] = useState(intialData);

// This will mutate the state
setFirstName(newValue);

// To not mutate the state, we can pass a function to the returned function. This is the better option though.
setFirstName((initialData) => newValue);

These two method works. The function returned by the useState hook receives the state data as argument and this is what we are going to leverage on on the Pro aprroach.

To update the state data in the Pro approach, we do the folowing,

  • Pass a function to the returned function by the useState hook,

  • Return a new object

  • Unpack the initial data or the we can as well say the current data stored in the state using the spread operator in the new object that is to be returned.

  • update the specific data e.g, firstName

All these steps are shown in the snapshot below

const [form, setForm] = useState({
  email: "",
  firstName: "",
  lastName: "",
  emailIsValid: false,
  firstNameIsValid: false,
  lastNameIsValid: false,
});

const firstNameOnChangeHandler = (e) => {
  setForm((prev) => {
    return { ...prev, firstName: e.target.value };
  });
};
  • { ...prev, firstName: e.target.value }: The newly created JavaScript object that will be returned or store in the state.

  • ...prev: This is us spreading or unpacking all we have in form state.

  • firstName: e.target.value: This is us updating the value stored in the firstName field.

To use any of the field or property in the state object, e.g, email, we get to it by chaining the key to the form object as shown below e.g, form.email, form.firstName and so on.

And that is it for this post.

Conclusion

The useState hook is a very important hook in a React application and so it is essential to know how to use it properly as React developers.

Another hook that can be used in place of useState hook is the useReducer hook. Unlike the useState hook, the function to update state will be declared by the developer itself, this is what makes it more efficient than useState hook if yo have complex logic for updating your state.

Kindly drop a comment describing where I should improve on. Thanks for reading.

2
Subscribe to my newsletter

Read articles from Joel Ojerinde directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Joel Ojerinde
Joel Ojerinde

Joel is a student of Electrical and Electronic Engineering who also works as a Full Stack Web Developer with a focus on Frontend development. He is skilled in writing clean, understandable, and maintainable code, as well as technical writing.