Skip to content

How does prevState in React work under the hood?

Published:

We have always been told to use prevState when dealing with useState but not really why we need it in the first place. Today, we will deep dive and see how it works under the hood to retrieve the latest state without the need of render cycle - render cycle refers to VDOM updates, not actual browser refresh. But before going forward, first, we need to see how the real problem occurs when the state is used instead of prevState.

const [counter, setCounter] = useState(0);
  return (
    <div className="App">
      <div>Counter: {counter}</div>
      <button
        onClick={() => {
          setCounter(counter + 1);
          setCounter(counter + 2);
          setCounter(counter + 3);
        }}
      >
        Click me to increase the counter!
      </button>
    </div>

In reality, this should increase the Counter by six each time we click, but it’s only taking the last one to account. So what’s the reasoning behind this? Is this working incorrectly, or is this the intended behaviour? It turns out it’s not falsy or incorrect; it is working as expected programmatically, at least. Because for React to access the counter state, it should complete its rendering cycle. But, since we force React to read the counter state before the cycle completion, it’s only referring to the last one.

Okay, let’s see how it behaves when we introduce prevState.

const [counter, setCounter] = useState(0);
  return (
    <div className="App">
      <div>Counter: {counter}</div>
      <button
        onClick={() => {
          setCounter(prevState => prevState + 1);
          setCounter(prevState => prevState + 2);
          setCounter(prevState => prevState + 3);
        }}
      >
        Click me to increase the counter!
      </button>
    </div>

Now it’s working as we expected. But, how? To answer this question, we’ll build a simple React clone and see how it internally manages prevState.

React used to rely on this in class-based components, but now it’s using closures under the hood to manage hooks states. Pretty much all of the hooks use closures to access information about previous renders.

A little recap for closures to not get lost in the following examples.

Closures

Consider the following code:

const add = () => {
  let counter = 0;
  return (x = 1) => {
    counter += x;
    return counter;
  };
};

const foo = add();

foo(5); // 5
foo(5); // 10

Closure functions always hold a reference to an inner variable to keep track of it. The inner function is only accessible within the function body, and this inner function can access counter at any time. So between function calls counter variable will always point to the latest variable state.

In the example above, if we go ahead and use a regular function, we would end up with 5 twice, but since we keep track of value inside function thanks to closure, we keep adding to the accumulated value.


Now, going back to our original example. We will build a simple React clone that utilizes closures under the hood to persist states between renders.

function Counter() {
  const [count, setCount] = React.useState(5);

  return {
    click: () => setCount(count + 1),
    _render: () => console.log("_render:", { count }),
  };
}

At first glance, you are probably saying we need an object with two functions, one to take care of useState and another one for our pseudo rendering. And definitely, a variable to persist the state.

const MyReact = () => {
  let val = null;

  return {
    render(Component) {
      const Comp = Component();
      Comp._render();
      return Comp;
    },
    useState(initialValue) {
      val = val || initialValue;
      const setState = nextState => (val = nextState);
      return [val, setState];
    },
  };
};

Let’s start with render(). The render() function accepts a component, and all it does is invoke the _render() and return the component for future use because we need to keep its reference. Without return Comp, we can invoke neither click nor _render because it’s this function that carries the details about our component.

The useState() is pretty straight forward. It takes the default value and assigns it to val, but only val is not present. Then, we have setState() to assign new values to our state. Finally, we return a tuple - array with 2 elements.

const MyReact = () => {
  let _val = null;

  return {
    render(Component) {
      const Comp = Component();
      Comp._render();
      return Comp;
    },
    useState(initialValue) {
      _val = _val || initialValue;
      const setState = nextState => (_val = nextState);
      return [_val, setState];
    },
  };
};

const React = MyReact();
function Counter() {
  const [count, setCount] = React.useState(5);

  return {
    click: () => setCount(count + 1),
    _render: () => console.log("_render:", { count }),
  };
}

let App;
App = React.render(Counter); // _render: {count: 5}
App.click();
App.click();
App.click();
App = React.render(Counter); // _render: {count: 6}

Now, if we run this piece of code, it only prints twice because we called render twice - that’s pretty expected. But, we clicked three times; why did it print count 6 instead of 8. Similar to real React our MyReact is waiting for React to render. Without render, it cannot process the upcoming state updates. Therefore relies on render.

let App;
App = React.render(Counter); // _render: {count: 5}
App.click();
App = React.render(Counter); // _render: {count: 6}
App.click();
App = React.render(Counter); // _render: {count: 7}
App.click();
App = React.render(Counter); // _render: {count: 8}

If we let it render, then it prints correctly.

So, how can we access the _val inside MyReact? You guessed it right, we need to give a callback to setCount and change the useState a bit. And, if you are worried about callback, don’t, because it’s something we already know and use.

useState(initialValue) {
      _val = _val || initialValue;
      const setState = (nextState) => {
        _val = typeof nextState === "function" ? nextState(_val) : nextState // Changed this line to accept callbacks
      }
      return [_val, setState];
}
const React = MyReact();
function Counter() {
  const [count, setCount] = React.useState(5);

  return {
    click: () => setCount(prevState => prevState + 1), // Sending callback to access closure
    _render: () => console.log("_render:", { count }),
  };
}

In setCount all we do is giving an arrow function that accepts a variable and adds 1 to it.

setCount(prevState => prevState + 1);

const setState = incVal => {
  _val = typeof incVal === "function" ? incVal(_val) : incVal;
};

We no longer need to rely on render cycles, we can directly access the state closure via prevState.

let App;
App = React.render(Counter); // _render: {count: 5}
App.click();
App = React.render(Counter); // _render: {count: 6}
App.click();
App = React.render(Counter); // _render: {count: 7}
App.click();
App.click();
App.click();
App = React.render(Counter); // _render: {count: 10}

By the way, this does not mean we need render anymore. Whether you like it or not React keeps rendering, but we can always get fresh states during rendering phase instead of stales one.

Wrapping Up

Some of the concepts above might seem vague, but in due time with lots of pratice they start become more understandable.

Important takeaways:

  • Functional components use closures under the hood to store states.
  • Always rely on prevState to avoid stale states.
  • Learning the core concepts of the language will always help to get deeper understanding