Rahul Agarwal
Rahul Agarwal

Decoding React Hooks — Building a Prototype from Scratch

To better understand how hooks work in React, I set out to try and implement a simplified replica. At the heart of it lies one of the most fundamental features of Javascript, closures, an introduction to which serves as an appropriate prelude to this article.

What are closures?

As defined by MDN:

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).

Best explained with the help of a simple example

// A call to makeAdder returns a function that has access to the argument x throughout it's lifespan.
const makeAdder = (x) => (y) => x+y;

const adder1 = makeAdder(1);
const adder2 = makeAdder(2);

console.log(adder1(10)); // 11
console.log(adder2(10)); // 12

Closures enable functions to have access to private variables.

Implementing useState

Let's look at the basic interface for useState

namespace React {
  /**
   * @param initialValue the initial value
   * @returns a stateful value and a function to update it
   */
  export interface useState {
    <T>(initialValue: T): [T, (value: T) => void];
  }
  }
}

From the initial introduction to closure, we can deduce that useState employs a private variable to maintain our state value and exposes a function to update it.


Tracking a single state value

let stateValue;

const useState: React.useState = <T>(
  initialValue: T
): [T, (currentValue: T) => void] => {
  stateValue = stateValue ?? initialValue;

  return [
    stateValue,
    (newValue: T) => {
      stateValue = newValue;
    },
  ];
};

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

  return {
    logState() {
      console.log(`Count: ${count}`);
    },
    click() {
      setCount(count + 1);
    },
  };
};

let element = Component();
element.logState(); // Count: 0

element.click(); // Increment count;
element = Component();

element.logState(); // Count: 1
element.logState(); // Count: 1

The above is the most basic implementation of useState which keeps track of our one state value in a global variable. Ideally, we want all data related to React, including our state variable to be encapsulated within our library to prevent it from any unintended manipulation, which we can achieve using an IIFE expression as you will see in the next section.


Tracking multiple state values

Now what happens if we need to track multiple state values instead of just one? We need to store a collection of state values in an array instead.

// Encapsulating all values and methods for React with this IIFE.
const React = (function () {
  let stateValues = [];
  let hooksIndex = 0;

  const useState: React.useState = <T>(
    initialValue: T
  ): [T, (currentValue: T) => void] => {
    // Increment the hooks index everytime.
    const _locallyScopedIndex = hooksIndex++;

    const stateValue = (stateValues[_locallyScopedIndex] as T) ?? initialValue;

    return [
      stateValue,
      (newValue: T) => {
        stateValues[_locallyScopedIndex] = newValue;
      },
    ];
  };

  /**
   * We shift the responsibility of rendering our component to React,
   * so it can reset the hooksIndex to 0 everytime our application is rendered.
   */
  const render = (component) => {
    hooksIndex = 0;
    return component();
  };

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

const Component = () => {
  const [count, setCount] = React.useState(0);
  const [name, setName] = React.useState("foo");

  return {
    logState() {
      console.log(`Count: ${count} & Name: ${name}`);
    },
    click() {
      setCount(count + 1);
    },
    type(newName: string) {
      setName(newName);
    },
  };
};

let element = React.render(Component);
element.logState(); // Count: 0 & Name: foo

element.click(); // Increment count;
element = React.render(Component);
element.logState(); // Count: 1 & Name: foo

element.type("bar"); // Change name
element = React.render(Component);
element.logState(); // Count: 1 & Name: bar

Some important characteristics of React hooks that become evident from the implementation above:

  1. Every invocation of a hook is assigned an address, hooksIndex in our case. Hooks must be invoked in the same sequence for each render cycle to ensure they have access to the correct address assigned to them, which enforces the following Rule of Hooks:

    Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function, before any early returns.

  2. The set function only updates the state variable for the next render. If you read the state variable after calling the set function, you will still get the old value that was on the screen before your call.

Implementing useEffect

namespace React {
  ...

  /**
   * @param cb the callback to run when dependencies change.
   * @param dependencies a list of dependencies that determine if React should run our effect.
   */
  export interface useEffect {
    (cb: () => void, dependencies?: StateValues): void;
  }

  ...
}

useEffect builds on top of useState essentially storing the dependencies as an application state.

const React = (function () {
  ...
  const useEffect: React.useEffect = (cb, dependencies) => {
    const oldDependencies = stateValues[hooksIndex] as
      | Array<unknown>
      | undefined;

    if (
      // Run the effect every time if dependencies are undefined
      // or only once, if dependencies is an empty array.
      !oldDependencies ||
      // Check if any of the values in the dependencies array has changed
      (dependencies as Array<unknown>).some(
        (val, index) => !Object.is(val, oldDependencies[index])
      )
    ) {
      cb();
    }

    // Increment hook index and update the dependencies
    stateValues[hooksIndex++] = dependencies;
  };

  ...
})();

const Component: React.FC = () => {
  const [count, setCount] = React.useState(0);
  const [name, setName] = React.useState("foo");

  // Run the effect every time `name` changes.
  React.useEffect(() => {
    console.log(`Name changed to: ${name}`);
  }, [name]);

  return {
    ...
  };
};

let element = React.render(Component);
element.logState();

element.click();
element = React.render(Component);
element.logState();

element.type("bar");
element = React.render(Component);
element.logState();

// Outputs:
// Name changed to: foo
// Count: 0 & Name: foo
// Count: 1 & Name: foo
// Name changed to: bar
// Count: 1 & Name: bar

Our effect runs only when the name state changes as expected. The implementation above uses Object.is to compare elements of the dependencies which is also true for React.

Also worth noting is one important anomaly in our implementation. We are running our effect as soon as the useEffect hook is invoked, while in React,

..effects defined with useEffect are invoked after render. To be more specific, it runs both after the first render and after every update. In contrast to lifecycle methods, effects don’t block the UI because they run asynchronously.


Customs hooks

Interestingly, at this time, we can extract our stateful logic into a custom hook and it should work as expected.

const useCustomHook = () => {
  const [count, setCount] = React.useState(0);
  const [name, setName] = React.useState("foo");

  React.useEffect(() => {
    console.log(`Name changed to: ${name}`);
  }, [name]);

  return {
    count,
    setCount,
    name,
    setName,
  };
};
const Component = () => {
  const { count, setCount, name, setName } = useCustomHook();

  return {
    ...
  };
};
...

Exercise for the reader

Try recreating the useMemo and useCallback hooks. You can check my implementation here,

Code sandbox for the article


Hope you enjoyed the read.

✌🏾

Send Rahul Agarwal a reply about this page
More from Rahul Agarwal
Back to profile