React has two types of components - functional & class. Traditionally, only class components were allowed to have state and lifecycle methods. However, that changed with the introduction of hooks in React 16.8.

Now functional components also have access to state and lifecycle methods. For simple states, we use the useState hook and for handling more complex states, we use the useReducer hook. For the lifecycle methods, we use the useEffect hook.

In this article, we will be covering the useState hook in an in-depth fashion. The ultimate goal of this article is to hopefully make sure that you don't have to go elsewhere to learn more about useState.

What are hooks anyway?

Hooks were introduced to React in version 16.8. They are just regular JavaScript functions. Hooks helps us "hook into" state and lifecycle methods from functional components.

The terminology makes a lot of sense since only class components were allowed to have state or lifecycle methods.

This isn't to say that behavior or implementation of hooks is the same as that of setState or lifecycle methods in class components. That is far from true - as you will soon see in the case of useState.

For now, note that that hooks allows us to use state and lifecycle methods in functional components. That was ultimately the purpose of class components making class components a legacy feature in react.

Hooks take advantage of something called the fiber architecture in React. This article won't cover fiber architecture in much detail. However, we will be looking specific cases for useState.

The useState hook

Today, we will specifically be talking about the useState hook. The useState hook allows us to have simple state in functional components.

It is a named export from the React library meaning we can import it as follows:

import React, { useState } from "react"

Or you can simply just use the . operator:

import React from "react"

React.useState() // also works

The useState hook takes 1 argument which is the initial state and returns an array. The array contains 2 elements which is the state variable and the setter function.

So if we were to take a look at the basic structure of the useState hook, it will be something like this:

export function useState(initialState) {
  // logic
  return [state, setState]
}

On the initial render, the state will be same as the initialState. However, initialState is ignored after first render. Hence, state variable will always be returning the current state.

If there is no initial state, then state variable will return undefined on the first render.

The setter function (setState) is used to change the value of the state.

Basic usage

First we import useSate from React.

import React, { useState } from "react"

Then we simply use array destructuring to access the state variable and setter function while passing intial state as an argument to the useState hook.

// ..

function Counter() {
  const [count, setCount] = useState(0)

  return <p>{count}</p>
}

// renders 0 in the UI

PS: At this point, we can also lazy initialize the initial state. We'll talk more about that down the road.

It is possible to use array index as well.

//..
const count = useState(0)[0]
const setCount = useState(0)[1]
//..

Although this works fine, it is obviously inconvenient.

Right now, you might be thinking why the useState just doesn't return an object instead. That's because we will have to go through extra syntax to change the variable names.

// if useState returned an object
const { state: count, setState: setCount } = useState(0)

However, it makes sense to return objects if there are large return values. That is because we can selectively destructure object properties. There are third-party libraries that has hooks which utilize this feature. But react's default hooks return only few values making it unnecessary.

Updating the state

To update the state, we use the setter function that is returned by the useState hook. In the above example, it is the setCount function.

setCount(1)

When this function is executed, we update the state variable with value 1. Technically, a new state variable is created with the value 1. We will talk more about that later. For now, using the setter function like above is not necessarily very useful.

Normally, we will use the setter function inside a click handler, the useEffect hook, or directly inside the onClick attribute.

//..
return (
  <>
  <p>{count}<p>
  <button onClick={() => setCount(count + 1)}>Increment</button>
  </>
)
// increments by 1 on every click

Now when we click the button, the count variable increments by 1. However, it is important to note that setter functions are actually asynchronous. So when we click the button, we are just adding the update to a queue. It is not executed immediately. This is has to do with the fiber architecture of react.

Right now, we have gone through most basic use of the useState hook. We have,

  • Imported it
  • Passed initial state
  • Destructred the state and setter function
  • Used the setter function to update the state

PS: We have for now only passed a number as the state. We can really pass anything. We will be taking a look at each specific case soon. For now, let's take a look at two advanced useState patterns. Understanding them would complement rest of the article's contents.

Advanced useState patterns

1. Lazy initialization

Earlier I mentioned that we can lazily initialize a state. What lazy initialization means is that we will be assigning the state value only when we need it. That means the state value won't be passed to the state variable when react first renders.

You might be thinking why that is even necessary. Well, if the state value is a result of some strong computation, for example, a parsed JSON coming from an API call. In that case, you don't want the UI to freeze until that computation is complete and state is initialized. Needless to say, that will be bad user experience.

So what you do is in fact lazily initialze the state. That's very simple, all you have to do is to pass the state inside the return value of a function.

//..
const [count, setCount] = useState(() => 0)
//..

In the above case, the function is the one that is passed as initial state to the useState hook. The useState hook is defined in such a way that if a function is passed, then it will execute and assign the return value to the state variable. What it won't do is keep the useState process alive until the computation is complete. So it make sures that UI is rendered regardless of whether the state was initialized.

You can also define a function outside and just pass it to the useState like so.

function computation() {
  // hard hitting computation
}

//..
const [state, setState] = useState(() => computation())
//..

Here the computation() function is run as needed and won't block the UI rendering.

2. Using previous state to update current state

Let's revisit the above example.

const React, {useState} from 'react';

function Counter() {
  const [count, setCount] = useState(0)
}

return (
  <>
  <p>{count}</p>
  <button onClick={() => setCount(count + 1)}>Increment</button>
  </>
)

Here we are directly passing the state variable and adding 1 inside the setCount(). Problem with this approach is that we cannot do a second update with the updated value. For example,

function Counter() {
  const [count, setCount] = useState(0)
}

function handleClick() {
  setCount(count + 1) // returns 0 + 1 = 1
  setCount(count + 2) // returns 0 + 2 = 2
}

return (
  <>
    <p>{count}</p>
    <button onClick={() => handleClick()}>Increment by 3</button>
  </>
)

// final state value will be 2 not 3

In the above case, you would think that at first count will be 1, then it will add 2 to become 3. Then you're thinking wrong.

React only really updates the state variable after a UI update. React has two phases when a component updates:

  1. The render phase
  2. The commit phase

In the commit phase, the UI is actually updated. But during the render phase, the component has to be in the same state. Meaning if you call multiple setter functions, all the setter functions will be initialized with the same state.

In the above case, both setCounts were initialized with 0 and added to the queue. Because JavaScript takes the last value as the final, the state value was 2.

Now react team obviously knows this is going to be a problem. Because we definitely want to be able to use updated values in the same rendering phase at some point.

The solution here is very similar to lazy initialization. We just pass arrow functions instead. The arrow functions accepts 1 argument which will be the previous state.

//..
function handleClick() {
  setCount(prevCount => prevCount + 1) // returns 0 + 1 = 1
  setCount(prevCount => prevCount + 2) // returns 1 + 2 = 3
}
//..

This works because we are passing a function and the setter function is defined in such a way that if it encounters a function as an argument, then it will automatically pass the previous state as the first argument of that function.

So both the useState() hook and the setter function has specific check to see if the value passed is a function. Otherwise, they work as normal giving as the above 2 patterns.

Using useState with primitive data types

We already used the useState with a primitive data type - number. But really, you can also use other primitive data types like boolean or string.

//..
const [isLoggedIn, setIsLoggedIn] = useState(false) // using boolean
const [name, setName] = useState("Elon Musk") // using string
//..

It is important to understand that when we change the value of isLoggedIn or name, we are actually creating a new variable with that value. That's just how JavaScript works. This is not relevant for primitive data types but will be relevant for arrays and objects as you will soon see.

It is also important to make sure that you update the state variable using setIsLoggedIn or setName function. Otherwise, the react library won't know the update has taken place. You'll still update the value, but rendering won't take place.

isLoggedIn = true
console.log(isLoggedIn) // prints "true" in console

// however, rendering will not take place
// because react doesn't know state variable has changed

// the right way to do it
setIsLoggedIn(true)

The setIsLoggedIn function will assign true to the state variable, but this time it will also re-render the component. So the entire UI logic is executed again with updated state value.

Using useState with arrays

We can also have arrays as our state variable.

//..
const [list, setList] = useState(["Apple", "Orange", "Grape"])
//..

Now it is important to keep in mind that we are creating a new array with updated value. So when we do update them, make sure to clone the original array first and then update the new values.

//..
setList([...list, "Banana"])
//..

Here the spread operator ... will clone the contents of the existing array and finally Banana will be added to the end.

Or you can add Banana at the top by exchanging the position.

//..
setList(["Banana", ...list])
//..

If you want to remove an element, then you have to use regular JavaScript logic to create a new array and then just update the state as follows.

//..
const newList = list.filter(item => item !== "Grape") // or some other logic
setList([...newList])

// list becomes ["Apple", "Orange", "Banana"]
// (Grape is filtered out)

You can use pop(), splice(), shift() and other methods to remove items from the array. This is where your regular JavaScript skills matter. For example, you might have to deal with objects inside the arrays, etc.

Dealing with multidimensional arrays

Multidimensional arrays are arrays that have other arrays within them. The spread operator only goes one level deep when copying content. So it won't behave as intended with multidimensional arrays. If you find yourself dealing with multidimensional arrays, you're better of trying to redo logic to handle simpler state objects.

You can still use array methods like map() then create complex logic to deal with them. It's just not ideal.

Using useState with objects

We can also pass objects as state in the useState hook.

//..
const [user, setUser] = useState({
  firstName: "Elon",
  lastName: "Musk",
})
//..

It is once again important to keep in mind that we will be creating a new object during each state update. So we must clone the original object first and then do the update.

//..
setUser({ ...user, age: "50" })
//..

Please not the syntax difference. For arrays, we use the [] brackets and for objects, we use {}. Well, we are creating a new array/object.

We can also update an existing property like so:

//..
setUser({ ...user, lastName: "Dusk" })

// user becomes {
// firstName: "Elon",
//  lastName: "Dusk",
//}

Dealing with nested objects

The same problem persists with the spread operator here as well. The spread operator only goes one level deep. So if we have nested objects, it will not be cloned by spread operator. Imagine the situation:

const person = {
  firstName: "Elon",
  lastName: "Musk",
  childRandom: {
    firstName: "Griffin",
    lastName: "Musk",
  },
}

const [user, setUser] = useState(person)

Let's suppose we have to add age of Griffin Musk. How would you do it? I imagine it would be like this:

//..
setUser({...user, childRandom.age = 18}) // wrong

This doesn't work as spread operator does only shallow copying. Means the ...user contains only properties that are one level deep. So you have to infact use spread operator again to go deeper.

//..
setUser({...user, childRandom: {
  ...user.childRandom, age: 18
}

As you can see, we used spread operator to create another shallow copy inside childRandom. This can get complicated with deeply nested objects. To avoid that, you can just call multiple useState hooks.

const father = {
  firstName: "Elon",
  lastName: "Musk",
}

const child = {
  firstName: "Griffin",
  lastName: "Musk",
}

const [userOne, setUserOne] = useState(father)
const [userTwo, setUserTwo] = useState(child)

When to use objects or multiple useState calls is upto you. But going too deep with nested objects is always a bad idea.

PS: We can still use the advanced useState pattern to use the previous state by passing an arrow function. It works for primitives, arrays, and objects.

Using props as initial state

We can pass props as the initial state, but don't expect the state to change if the props changes. For example, if we write something like this:

//..
function User(props) {
  const [name, setName] = useState(props.name)
}

Here we are passing a prop as the initial state. If this prop changes after first render, it will not affect useState in anyway. The useState hook takes the initial value during first render and cares to update only when the setter function is called. So prop changes won't change state.

In this case, you can use the useEffect hook with that specific prop as a dependency and call the setter function instead.

//..
function User(props) {
  const [name, setName] = useState(props.name)
}

useEffect(() => {
  setName(props.name)
}, [props.name])

If you don't know useEffect hook, it is used to imitate lifecycle methods. It takes 2 arguments,

  1. A function
  2. A dependency array

The body of the function contains stuff we need to execute and the dependency array determines what changes trigger the useEffect.

A useEffect is always run once in the initial render. So technically you need not pass the intial state in the above case. But really it doesn't hurt.

Fiber architecture & the updation process

As I already mentioned, react (and by implication react hooks) take advantage of something called the fiber architecture. Discussing fiber architecture in detail is beyond the scope of this specific article. But we still need to understand how fiber architecture is utilized with the useState hook.

So fiber architecture has two phases,

  1. The render phase
  2. The commit phase

We already discussed this in brief before. The render phase is completely asynchronous. Meaning we can actually start and stop processes inside the render phase. The commit phase is however synchronous and cannot be disturbed.

The fiber architecture works in a way that it prioritize certain processes over others. In react, we can call them work. So react prioritize certain work over others.

A fiber is simply a unit of that work. So a fiber could be something like a state update. Really it could be any piece of logic that gets something done.

What you need to keep in mind is that react fiber has a priority list and on top of that is the UI update. That's why commit phase is actually synchronous. Which means it can never or should never be interrupted.

On the render phase however, everything works asynchronously. So there is clearly lot of interruptions based on priority.

As a result of that, when we update the state using the setter function, what's really happening is that we are adding the updation process to a queue.

All state updates are called in order, which is why you must declare them in proper order as well. Because the ones that are declared first are prioritized over the ones that are declared later.

Each of these state updates end by pointing to the next state update. Hence that actually forms a tree and that tree is executed top to bottom until nothing is being pointed at any more.

All these complex logic are abstracted away from us but what it does is something quite wonderful. The fibre architecture not only makes react fast, it also makes it smart.

Now coming to the actual updation process..

As I already mentioned, react fiber has 2 phases - the render phase & the commit phase. When the useState hook is executed, the whole process goes through both of these phases.

The render and commit phase in works like this:

The render phase

If a useState or useReducer is present, the state value is initialized.

The JSX of the component gets converted to react elements. This is done using the React.createElement() method.

This in turn creates a component tree. This is simply an object representation of the actual component. We call this the virtual DOM.

The commit phase

In the commit phase, the real DOM is updated with the values of the virtual DOM.

This is all fine if our component only renders once.

But interesting things happen when we have a useState or useReducer in our component. That's because having a state means a component can flag itself for re-renders.

In this case, a component is flagged when we run the setter function. (Or dispatch for useReducer).

PS: Re-renders can also happen if there is a change in prop or due to parent re-render. We are only talking about re-renders that happens due to setter functions in useState.

During re-renders, the render phase and commit phase acts a bit differently.

In the render phase, the state variable is checked to see if there is a change of value.

If there is, the React.createElement() is called again and JSX is converted to a component tree. This time, the newly created tree is compared to the current tree to see if there are any changes. The changes are applied to the virtual DOM and changes are passed to the commit phase.

If the state variable hasn't changed, then react bails out from the rendering process altogether.

During the commit phase, the real DOM is updated as usual. Note that only changes are updated as only changes are passed to the commit phase by react.

Safety net

When a component re-renders for the first time and state variable hasn't changed, react will go through the render phase anyway. It will however bail out from the commit phase. This won't happen for subsequent renders.

This is just a safety feature employed by react.

Rules of react hooks

Okay, now let's discuss something much simpler. React hooks have certain rules (well, 2) that must be obeyed. The rules are simple,

  1. A hook must be called only on the top-level of the component.
  2. A hook must be called only on functional components.

The second rule is obvious. We don't need hooks in class components. The entire purpose is to dumb class components altogether.

However for the first one, a hook should be called like this:

function App() {
  const [state, setState] = useState(0) // or any other hook
}

That means you cannot call a hook inside a condition or other hooks like useEffect etc.

This is because react has to call those hooks first and in same order before it does anything else. Otherwise, it cannot preserve the state.

The useReducer hook and handling complex states

Okay, I know this is a long article already. But it isn't complete without briefly touching about the useReducer hook.

The useReducer hook also helps us manage state in react. In fact, the useState is an abstraction from the useReducer hook. Behind the scenes, we are still using useReducer with some fixed conditions which makes it behave like useState.

A useReducer hook accepts initial state and a reducer function and returns an array. The array has the state variable and dispatch method.

import React, { useReducer } from "react"

function App() {
  const [state, dispatch] = useReducer(reducer, initialState)
}

The reducer function is simply a function that contains a set of conditions and returns something depending on that condition. The best way to accomplish it is by using the switch statement but no one is stopping from using something like if-else instead.

A simple implementation of useReducer with example of counter:

const initialState = { count: 0 }

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 }
    case "decrement":
      return { count: state.count - 1 }
    default:
      throw new Error()
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  )
}

The idea here is simple. Whenever the dispatch function is fired, it dispatches its argument to the reducer function. The reducer function matches that with predefined conditions and return a new state. This way, more complex states can be managed. You could possibly emulate this using the previous state pattern. Of course, that will be more complex.

With that, I will wind up this article. If you made it till here, congrats.

Written by Aravind Sanjeev, an India-based blogger and web developer. Read all his posts. You can also find him on twitter.