Building React.useState From Scratch

Shoeb IlyasShoeb Ilyas
5 min read

In React, hooks are just functions that let you preserve state between renders. One of the most widely used hooks is useState.

When you call useState, it gives you two things:

  • The current value of the state

  • A function to update that state

The key is that React remembers the state between renders. To better understand how this works, we’ll build our own simplified version of useState.

The Simplest Possible useState

A very basic implementation could look like this:

function useState(initState) {
  let _val = initState; // The internal state

  let state = () => _val; // Getter
  let setState = (newVal) => {
    _val = newVal; // Setter
  };

  return [state, setState];
}

const [state, setState] = useState(0);

console.log(state());
setState(3);
console.log(state());
  • Every time you call the state getter function, you get the latest value.

  • When you call setState, it updates that value inside a closure.

This works, but it has three major problems:

  1. It only works for one piece of state

  2. It doesn’t persist across multiple renders of a component. Moreover, we don’t have any Component at this point but we will be building one in this article

  3. Calling state() as a function (or its setter) is not how useState normally works in React

Our To-Do

In React, a component can have multiple useState calls. To mimic this, our implementation needs to:

  • Remember which state belongs to which useState call

  • Keep track of the order in which they’re called

  • Persist them across renders

React does this internally using an array of state values and a pointer to track which one is currently being accessed.

Introducing a React Namespace

To organize things, we’ll wrap our hooks logic inside an IIFE (Immediately Invoked Function Expression).

const React = (() => {

    function useState(initState) {
        // ... Our Original Logic Goes Here
    }

  return {
    useState,  
  };
})();

This creates a React namespace that clearly separates the implementation from its usage — similar to how the real React library is structured.

For testing, we’ll define a minimal Component function.

const Component = () => {
  const [count, setCount] = React.useState(3);

  return {
    render: () => {
      console.log(count);
    },
    onClick: () => {
      setCount((prev) => prev + 1);
    },
  };
};

It returns an object with two functions:

  • render: Logs the current value of state. This simulates a UI render so you can see values change in real time.

  • onClick: Updates the state using the setter function, mimicking a UI event like a button click.

Improving Persistence

Our first improvement is moving _val outside of the useState function, making it a persistent variable. With this change, the getter and setter behave more like React’s useState.

const React = (() => {
  let _val;

  function useState(initState) {
    let state = _val || initState;
    let setState = (newVal) => {
      if (typeof newVal === 'function') {
        _val = newVal(state);
      } else _val = newVal;
    };
    return [state, setState];
  }


  return {
    useState,  
  };
})();

Inside setState, we add this crucial logic:

  • If the new value is a function (e.g., setCount(prev => prev + 1)), we call it with the current state value and store the result.

  • If the new value is a direct value (e.g., setText("Hello World")), we store it directly.

This ensures that our setter matches React’s behavior, handling both direct value updates and functional updates that depend on the previous state.

Simulating Re-Renders

Next, we introduce a render function inside the React namespace. This function executes the component and returns it, letting us simulate re-renders — just like React does when state changes.

// ...Inside React namespace
const render = (Comp) => {
    let C = Comp();
    C.render();
    return C;
  };

///....Values Returned from the Namespace
return {
    render,
    useState,
}

However, our logic still only works for one variable. If we add multiple useState calls, the code breaks.

Supporting Multiple States

To fix this, we:

  • Rename _val to hooks and make it an array

  • Add an index variable to track which useState call is currently being processed

This ensures that each useState within a component gets its own independent slot in the hooks array.


Final Code

const React = (() => {
  let hooks = [];
  let index = 0;

  function useState(initialValue) {
    const currentIndex = index;
    hooks[currentIndex] = hooks[currentIndex] ?? initialValue;

    const setState = (newValue) => {
      if (typeof newValue === "function") {
        hooks[currentIndex] = newValue(hooks[currentIndex]);
      } else {
        hooks[currentIndex] = newValue;
      }
    };

    index++;
    return [hooks[currentIndex], setState];
  }

  function render(Component) {
    index = 0;
    const c = Component();
    c.render();
    return c;
  }

  return { useState, render };
})();

Usage Example

const Component = () => {
  const [count, setCount] = React.useState(3);
  const [text, setText] = React.useState("Hello");

  return {
    render: () => {
      console.log(text);
      console.log(count);
    },
    onClick: () => {
      setCount((prev) => prev + 1);
      setText("HELLO WORLD");
    },
  };
};

let renderer = React.render(Component);
renderer.onClick();
renderer = React.render(Component);

Step-by-Step Execution and Explanation

First Render

  • The hooks array is empty.

  • useState(3) sets count = 3 and stores it in hooks at index 0.

  • useState("Hello") sets text = "Hello" and stores it in hooks at index 1.

Render Output:

Hello
3

Handling Updates

  • renderer.onClick() triggers two updates:

    • setCount(prev => prev + 1) updates hooks from 34.

    • setText("HELLO WORLD") updates hooks from "Hello""HELLO WORLD".

Second Render

  • When React.render(Component) runs again, index resets so each useState matches the correct slot in hooks.

  • count now reads 4.

  • text now reads "HELLO WORLD".

Render Output:

HELLO WORLD
4

Conclusion

By re-creating useState from scratch, we’ve peeled back the curtain on one of React’s most powerful features. Instead of relying on magic, we can now see that React’s state management boils down to a simple idea: store values in an array and track them by their call order.

This exercise demonstrates how React:

  • Persists values across re-renders

  • Handles multiple independent pieces of state

  • Supports both direct updates and functional updates based on the previous state

While this is only a minimal simulation, the core principle is the same in React itself — just at a much larger scale with added optimizations, scheduling, and performance guarantees. Thank you for being patient with me.

0
Subscribe to my newsletter

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

Written by

Shoeb Ilyas
Shoeb Ilyas