DEV Community

Cover image for Wait... how does React.useState work?
Rembrandt Reyes (He/Him)
Rembrandt Reyes (He/Him)

Posted on • Updated on

Wait... how does React.useState work?

So React hooks have been released for a while now and they are great! I have used them in production code and it makes everything look nicer. As I continued to use hooks, I started to wonder how all this magic works.

Apparently I was not the only one because there was a Boston React meetup on this topic. Big Thank you to Ryan Florence and Michael Jackson (Not the Moonwalking legend) for giving such a great talk around the subject. Continue watching and you will learn more about useEffect and how that works!

How does it work?

You create a functional component and throw some React hook at it that tracks state, can also update it, and it just works.

Many of us have seen some variation of this example before:

One useState

import React from "react";

const App = () => {
  const [count, setCount] = React.useState(1);

  return (
    <div className="App">
      <h1>The infamous counter example</h1>
      <button onClick={() => setCount(count - 1)}>-</button>
      <span style={{ margin: "0 16px" }}>{count}</span>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

👏 👏 👏 It works!
Counter figure 1

Ok great, but how does it do that magic? Look at the React.useState line. It's so easy to read that I never questioned it. I have a destructed array that extracts the count value and some function called setCount and it will initialize count with the default value that I passed into useState. What happens when I add another React.useState to the picture?

Two useState, ha-ha-ha

Count Dracula anyone?

const App = () => {
  const [count, setCount] = React.useState(1);
  const [message, setMessage] = React.useState("");

  const adder = () => {
    if (count < 10) {
      setCount(count + 1);
      setMessage(null);
    } else {
      setMessage("You can't go higher than 10");
    }
  }

  const subtracter = () => {
    if (count > 1) {
      setCount(count - 1);
      setMessage(null);
    } else {
      setMessage("You can't go lower than 1, you crazy");
    }
  }

  return (
    <div className="App">
      <h1>The infamous counter example</h1>
      <button onClick={subtracter}>-</button>
      <span style={{ margin: "0 16px" }}>{count}</span>
      <button onClick={adder}>+</button>
      <p>{message}</p>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we are showing a message whenever a user is trying to go outside the bounds of 1 - 10
Counter Figure 2

In our component, we have two destructured arrays that are using the same React.useState hook with different default values. Whoa, now we are getting into the magic of it all.

Alright so lets delete our React from React.useState we should get a referenceError saying, "useState is not defined"
Reference error because React is gone

Let's implement our own useState function.

Reverse engineering the useState function

A useState function has a value and a function that will set that value

Something like this:

const useState = (value) => {

  const state = [value, setValue]
  return state
}
Enter fullscreen mode Exit fullscreen mode

We are still getting referenceErrors because we haven't defined setValue. We know that setValue is a function because of how we use it in useState
Our count useState: const [count, setCount] = React.useState(1);

Calling setCount: setCount(count + 1);

Creating the setValue function results in no more error but the - and + buttons don't work.

const useState = (value) => {
  const setValue = () => {
    // What do we do in here?
  }

  const state = [value, setValue]
  return state
}
Enter fullscreen mode Exit fullscreen mode

If we try and change the default value in useState it will update count 👍🏽. At least something is working 😂.
Counter figure 3

Moving on to figuring out what the hell setValue does.

When we look at setCount it's doing some sort of value reassignment and then it causes React to rerender. So that is what we are going to do next.

const setValue = () => {
  // What do we do in here?
  // Do some assigning
  // Rerender React
}
Enter fullscreen mode Exit fullscreen mode

We will pass in a new value argument to our setValue function.

const setValue = (newValue) => {
  // What do we do in here?
  // Do some assigning
  // Rerender React
}
Enter fullscreen mode Exit fullscreen mode

But what do we do with newValue within the setValue function?

const setValue = (newValue) => {
  // Do some assigning
  value = newValue // Does this work?
  // Rerender React
}
Enter fullscreen mode Exit fullscreen mode

value = newValue makes sense but that does not update the value of the counter. Why? When I console.log within setValue and outside of setValue this is what we see.

Counter does not update when + is clicked

So After I refresh the page. The count is initialized to 1 and the message is initialized to null, great start. I click the + button and we see the count value increase to 2, but it does not update count on the screen. 🤔 Maybe I need to manually re-render the browser to update the count?

Implement a janky way to manually re-render the browser

const useState = (value) => {
  const setValue = (newValue) => {
    value = newValue;
    manualRerender();
  };
  const state = [value, setValue];
  return state;
};
.
.
.
const manualRerender = () => {
  const rootElement = document.getElementById("root");
  ReactDOM.render(<App />, rootElement);
};

manualRerender();
Enter fullscreen mode Exit fullscreen mode

This still doesn't update count in the browser. What the heck?

I was stuck on this for a little while and now I know why. Let's console.log state right after we create it.

const state = [value, setValue];
console.log(state)
Enter fullscreen mode Exit fullscreen mode

Our call to useState causes the first render, and we get:
[1, setValue()]

And on our second call to useState we render:
[null, setValue()]

resulting in:
first render

To help visualize this a bit better, let's add a render tracker to count how many times we render the screen.

let render = -1

const useState = (value) => {
  const setValue = (newValue) => {
    value = newValue;
    manualRerender();
  };
  const state = [value, setValue];
  console.log(++render)
  console.log(state)
  return state;
};
Enter fullscreen mode Exit fullscreen mode

Track renders when useState is called

How does our setValue function know which value to update? It doesn't, therefore we need a way to track it. You can use an array or an object to do this. I choose the red pill of objects.

Outside of useState function, we are going to create an object called states

const states = {}
Enter fullscreen mode Exit fullscreen mode

Within the useState function initialize the states object. Let's use the bracket notation to assign the key/value pair.

states[++render] = state

I am also going to create another variable called id that will store the render value so we can take out the ++render within the brackets.

You should have something that looks like this:

let render = -1;
const states = {};

const useState = (value) => {
  const id = ++render;

  const setValue = (newValue) => {
    value = newValue;
    manualRerender();
  };
  const state = [value, setValue];
  states[id] = state;
  console.log(states);
  return state;
};
Enter fullscreen mode Exit fullscreen mode

What does our states object look like?

states = {
  0: [1, setValue],
  1: [null, setValue]
}
Enter fullscreen mode Exit fullscreen mode

So now when we click the add and subtract buttons we get... nothing again. Oh right because value = newValue still isn't doing anything.

But there is something that is happening. If you look at the console you will see that every time we click on one of the buttons it will keep adding the same arrays to our states object but count isn't incrementing and message is still null.

So setValue needs to go look for value, then assign the newValue to value.

const setValue = (newValue) => {
  states[id][0] = newValue;
  manualRerender();
};
Enter fullscreen mode Exit fullscreen mode

Then we want to make sure we are only updating keys: 0 and 1 since those will be our two useState locations.

So head down to the manualRerender function and add a call to render and reassign it to -1

const manualRerender = () => {
  render = -1;
  const rootElement = document.getElementById("root");
  ReactDOM.render(<App />, rootElement);
};
Enter fullscreen mode Exit fullscreen mode

We do this because every time we call setValue it will call the manualRerender function setting render back to -1

Lastly, we will add a check to see if the object exists. If it does then we will just return the object.

if (states[id]) return states[id];
Enter fullscreen mode Exit fullscreen mode

Now we work again!
Counter working

Phew. That was a lot to process and this is just a very simplistic approach to useState. There is a ton more that happens behind the scenes, but at least we have a rough idea of how it works and we demystified it a bit.

Take a look at all the code and try and make a mental model of how it all works.

Hope this helps 😊

Top comments (3)

Collapse
 
andashape profile image
AndAShape

This reminds me of ASP.net Viewstate. It was a structure that paralleled the structure of the UI control tree - there were no identifiers. Values were loaded into controls by matching the two structures. If you messed around with it then you could push values into the wrong control.

Collapse
 
rembrandtreyes profile image
Rembrandt Reyes (He/Him)

Thank you, appreciate your feedback :D.

Collapse
 
karsonkalt profile image
Karson Kalt

Great read