DEV Community

Cover image for All the Hooks Series: useState
Jamena McInteer
Jamena McInteer

Posted on

All the Hooks Series: useState

In this first post of the series, I will be going over the useState React hook. useState is one of the more straightforward and widely used hooks, so it makes a good foundation to learn when using React hooks.

Getting Started with useState

useState is used for local state management in a React function component. To get started with the useState hook, you need a React function component and you need to import the useState hook from React.

// import useState hook
import React, { useState } from "react";

// create function component
const Example = () => {
  // ...
}

Now we can declare a state variable. When using the useState hook, we declare the state variable in this format: const [value, setValue] = useState(initialValue);, where value is the name of the state variable (we get to name our state variables), setValue is a function that is used throughout the component to update the value of the state variable, and initialValue is the initial value of the state variable, if any. Note that setValue should be written in this format, with lowercase set followed by the variable, all in camelCase.

Note: This format we are using with square brackets is called array destructuring, where useState returns an array with two items and we turn those items into their own variables.

If we want multiple state variables, we would follow this format multiple times, calling useState multiple times. For example:

import React, { useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const [whatToCount, setWhatToCount] = useState();
}

When reading state, we use curly braces in our JSX or use the variable in our JavaScript like any other variable in scope.

import React, { useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const [whatToCount, setWhatToCount] = useState("apples");

  return (
    <p>There are {count} {whatToCount}.</p>
  )
}

To update state, we use the updater function that was created, setCount (or setWhatToCount). This updater function may be called in a button event, for example, or from a function or other hook in our component.

import React, { useState } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const [whatToCount, setWhatToCount] = useState();

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+</button>
      <p>There are {count} {whatToCount}.</p>
    </>
  )
}

It's important to note that the updater function doesn't update the value right away. If you need to do something with the updated value right away, you will need to use a variable that you set to the new value before you set the state, or you will need to move the work you need to do with the new value to a useEffect hook that runs when that piece of state changes. I don't want to go too much into useEffect for this post, but I will touch on it briefly.

Let's take this example:

const Example = () => {
  const [count, setCount] = useState(0);

  const addCount = () => {
    setCount(count + 1);
    console.log('count', count); // 0
  }

  return (
    <button onClick={addCount}>+</button>
  )
}

In this example, the console will log 0 since it runs before setCount has completed. If we want to print the new count (1), we need to do the following:

const Example = () => {
  const [count, setCount] = useState(0);

  const addCount = () => {
    const newCount = count + 1;
    setCount(newCount);
    console.log('count', newCount); // 1
  }

  return (
    <button onClick={addCount}>+</button>
  )
}

Or use useEffect:

const Example = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('count', count); // 1, after count has changed
  }, [count]);

  const addCount = () => {
    setCount(count + 1);
  }

  return (
    <button onClick={addCount}>+</button>
  )
}

Using useState with functional updater form

The useState hook is pretty straightforward, but there are cases you will run into as you do more advanced work where using something like setCount(count + 1) is not going to work well, and you will need to use the functional updater form of the updater function. I will review this more when we get to useEffect and useCallback, as that's really when the need to use this will come up, but I wanted to mention it here as well. This also comes up when working with async functions.

In some cases, using something like setCount(count + 1) will cause infinite re-rendering of your React app, causing it to crash. If you're using this in useEffect, for example, every time count changes the app may re-render. If setCount is running every time count changes, and count changes every time setCount runs, then you will get an infinite looping issue.

This is where the functional updater form of setState comes in handy.

If the new state is computed using the previous state, you can pass a function to setState. The function will receive the previous value, and return an updated value.

So now, if we need to use the previous value of count when updating it, instead of using setCount(count + 1), we would use: setCount(prevCount => prevCount + 1). This way there is no dependency on count. Again, if you're not familiar with useEffect and useCallback yet, this will make more sense later in the series.

This functional updater form is also useful when performing async operations. Take the following for example:

const Example = () => {
  const [count, setCount] = useState(0);

  const handleClickAsync = () => {
    setTimeout(function delay() {
      setCount(count + 1);
    }, 3000);
  }

  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>+</button>
    </div>
  );
}

In this example, because of the delay introduced with setTimeout, the value of count will be stale by the time setCount runs, giving us unexpected outcomes. What we want is to use the most recent value of count rather than the value that count was when setTimeout was queued. Using the functional updater form, we can change the example to this:

const Example = () => {
  const [count, setCount] = useState(0);

  const handleClickAsync = () => {
    setTimeout(function delay() {
      setCount(prevCount => prevCount + 1);
    }, 3000);
  }

  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>+</button>
    </div>
  );
}

Working with objects as state values

useState can hold any kind of value, including arrays and objects. However, because of the way JavaScript handles arrays and objects (by value vs by reference), you may run into issues where you're trying to update an array or object state value and the component doesn't re-render and display the new state as expected. This becomes especially apparent when you're working with an array of objects.

The state updater doesn't merge new values with old values, it overwrites the state with the new value. React uses Object.is to decide if an object (or array of objects) is different and whether it should re-render. If you try to update an existing object, it is considered the same object, which means React won't re-render. You have to pass a brand new object to change the state.

There are a couple of ways you can update a state value that is an object and ensure that React recognizes the change and re-renders the component. One is to use Object.assign to create a new object and set the state to use this value. The other is to use the ES6 spread operator to create a new object with the values of the old object and any changes.

For example, the following won't trigger a re-render since the existing state object is being mutated and to React / JavaScript, it's the same object.

const Example = () => {
  const [item, setItem] = useState({id: 1, value: ''});

  const editItem = () => {
    item.value = Math.random() * 100;
    setItem(item);
  }

  return (
    <button onClick={editItem}>Change the number</button>
  )
}

To make this work, a new object must be created.

Using Object.assign to create a new object:

const Example = () => {
  const [item, setItem] = useState({id: 1, value: ''});

  const editItem = () => {
    const newItem = {
      id: prevValue.id,
      value: Math.random() * 100
    }
    const updatedItem = Object.assign({}, newItem);
    setItem(updatedItem);
  }

  return (
    <button onClick={editItem}>Change the number</button>
  )
}

Using the ES6 spread operator to create a new object:

const Example = () => {
  const [item, setItem] = useState({id: 1, value: ''});

  const editItem = () => {
    setItem({
      ...item,
      value: value: Math.random() * 100
    })
  }

  return (
    <button onClick={editItem}>Change the number</button>
  )
}

Working with arrays as state values

To add an item to a state variable that is an array, we want to create a new array and add the item, using the ES6 spread operator. We'll want to do something similar when changing existing items.

For example, don't try to push Array.push to add new items or directly modify the current array to change values in the array.

Instead, use the spread operator to create a new array using the value of the old array and add the items to the new array:

const Example = () => {
  const [items, setItems] = useState([]);

  const addItem = () => {
    setItems([
      ...items,
      {
        id: items.length,
        value: Math.random() * 100
      }
    ]);
  }

  return (
    <button onClick={addItem}>Add a number</button>
  )
}

We can also do something like this if we want to change the value of an existing item in the array:

const Example = () => {
  const [items, setItems] = useState([]);

  const editItem = (id) => {
    setItems(prevValue => {
      const updatedItems = prevValue; // create a new array using the previous value
     // modify our new array 
     updatedItems.map((item) => {
        if (item.id === id) {
          item.value = Math.random() * 100;
        }
        return item;
      }
      return [...updatedItems]; // return our new array with modified values using the spread operator
    }
  }

  return (
    <button onClick={() => editItem(3)}>Change a number</button>
  )
}

Lazy initialization

From the React docs:

The initialState argument is the state used during the initial render. In subsequent renders, it is disregarded. If the initial state is the result of an expensive computation, you may provide a function instead, which will be executed only on the initial render

What does this mean? Normally, a value (which could be returned from a function) can be passed to useState to set the initial state of the state variable:

const [value, setValue] = useState(someFunction());

This initial state is used during the initial render but not in re-renders (rather whatever the state is set to using setValue, in this example). useState is called on every render, but the initial state value is disregarded after the initial render. someFunction() would still be called on subsequent renders, it's value is just going to be disregarded. If someFunction() is computationally expensive (maybe it deals with a large amount of data), having it called on re-renders would be bad for performance.

This is where we can use lazy initialization, so the function runs on the initial render only. We can do that by passing a function to useState that returns the result of the computationally expensive function. For example:

const [value, setValue] = useState(() => someFunction());

Updating state in an unmounted component (dealing with async updates)

Sometimes you'll be wanting to update your state after some async operation completes. However, if the component unmounts before the async operation is complete and the state update has had a chance to complete, you'll get a warning from React about updating state in an unmounted component.

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.

Again, this is more of a topic for the useEffect and useRef hooks, but I wanted to briefly mention it here. Assuming you have currently cancelled other async operations, like API calls and timers, you can use useRef to create a variable that will tell you if the component is mounted, and only update your state if it is. For example:

import React, { useState, useRef, useEffect } from "react";

const Example = () => {
  const _isMounted = useRef(true);
  const [apiRes, setApiRes] = useState();

  // Don't worry about this too much yet, but essentially _isMounted.current is set to `false` when this component unmounts
  useEffect(() => {
    return () => {
      _isMounted.current = false;
    }
  }

  // Again, don't worry about `useEffect` too much yet, but here we are performing some async operation and setting state after it's done. Using our `_isMounted` variable, we check if the component is mounted before we perform our state update.
  useEffect(() => {
    const someAsyncFunction = async () => {
      const res = await API.get("/api/some-api-endpoint");
      // Here is where we check that our component is still mounted before we make the state change
      if (_isMounted.current) {
        setApiRes(res.data);
      }
    }
  }, []);
}

Conclusion

That's it for this first post in the All the Hooks Series! Feel free to leave a comment if anything is confusing to you or if you find an error or want to start a conversation about any of the topics covered. We can all learn from each other! 🤗 Stay tuned for the next post in the series where I will be covering useEffect.

References

  1. Using the State Hook
  2. 4 Examples of the useState Hook
  3. A guide to useState in React
  4. The Wise Guide to React useState() Hook
  5. Understanding React Hooks — useState

Top comments (0)