An unexpected error using React hooks


The rule of hooks states that you shouldn’t call hooks inside a conditional statement or a function and when you violate it you get an error such as below. But what if you followed the rule but still get the error? In this post, we’ll explore this cryptic and unexpected error that can occur when using React hooks and how to solve it.

Invariant Violation: Hooks can only be called inside the body of a function component. (https://fb.me/react-invalid-hook-call)
    at invariant (http://localhost/static/js/main.chunk.js:121537:23)
    at resolveDispatcher (http://localhost/static/js/main.chunk.js:122922:36)
    at useRef (http://localhost/static/js/main.chunk.js:122956:28)
    at useStateWithGetter (http://localhost/static/js/main.chunk.js:39121:71)

Rule of hooks

Let’s have a look at what the rule of hooks is and why it needs to be followd.

React tracks the order of hooks as they are called. If the order of hooks changes, React throws an error. So, maintain the order in which hooks are called. This is the fundamental rule of hooks. It is important that it’s not violated because React has no way of knowing the associated state with a useState call. This can occur if we call hooks in a conditional statement or a function. For example, consider the code snippet below.

let firstRender = true;
function App() {
    if (firstRender) {
        const [count, setCount] = useState(0);
        firstRender = false;
    }
    const [text, setText] = useState("Hello!");
    return (
        <div>
            {count} - {text}
        </div>
    );
}

Let’s assume React doesn’t throw an error if we call the useState hook in a conditional statement. The count variable will be set to 0, and the text variable to “Hello!”, which is expected. On the next render, since the firstRender variable is set to false, the useState hook will not be called. This means that all the return values of hooks would be shifted by one, resulting in the text variable being set to 0. This is not what we want, and this is why React throws an error when we call hooks in a conditional statement or a function. The error is not limited to useState but also applies to other hooks such as useEffect, useContext, etc.

However, the rule of hooks can still be violated without using hooks inside a conditional or a function statement. Let’s first understand how.

Same instance

React is a peer dependency of ReactDOM. React builds a tree of elements, called a virtual DOM, but it is ReactDOM that takes care of rendering the real DOM in a browser. ReactDOM should resolve the same module of React from which hooks were imported because it is that module instance that knows the order of hooks. If ReactDOM couldn’t import the same “React,” the order of hooks will be different. This will violate the fundamental principle and cause an error.

The diagram illustrates that:

  1. “User Code” imports hooks such as useState, useEffect, etc.
  2. “User Code” also imports and utilizes ReactDOM to render the virtual DOM. This can be achieved either by calling ReactDOM.render or by creating a root using ReactDOM.createRoot and then invoking root.render.
  3. ReactDOM imports React to obtain the virtual DOM. It is crucial that this module instance remains the same as the one from which hooks were imported because that particular instance holds knowledge about the order of hooks.

Real-world examples

Now, let’s examine real-world scenarios where ReactDOM might resolve React differently from the one used to import hooks.

The first scenario is likely to occur in a library that offers reusable UI components. There’s a possibility that the library is utilizing React and simultaneously including it in the bundle, leading to the error.

Secondly, such a situation can arise in a monorepo development setup where each project is employing a different version of React, also resulting in an error.

Bundling issue

Let’s consider a scenario where we import and use a UI library that provides React components. For example, imagine we have a library that exports a default component named Page.

// "library" code
// main.js
import React from "react";

export const Page = () => {
    const [count, setCount] = React.useState(0);
    return (
        <div>
            <h1>Page</h1>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>Increment</button>
        </div>
    );
};

export default Page;

We also have an example that imports and utilizes this library to render the Page component. The example uses ReactDOM to render the virtual DOM.

// "example" code
// index.js
import React, { useState } from "react";
import ReactDOM from "react-dom";
import Page from "library";

const App = () => {
    const [showPage, setShowPage] = React.useState(false);
    return (
        <div>
            <h1>App</h1>
            <button onClick={() => setShowPage(true)}>Show Page</button>
            {showPage ? <Page /> : null}
        </div>
    );
};

The issue is that when the library is bundled it includes the react library with itself. The library imports hooks and creates VDOM using this react library bundled with it. This is different from the “React” which is used by the example. Also, the example then uses ReactDOM to render the VDOM. This ReactDOM uses “React” which is different from the one bundled with the library as seen in Fig-A. This causes the error.

Figure A (Fig-A: The light blue shaded container represents the library bundle. The arrows indicate import statements, with the arrow tail pointing to the module and the head pointing to where it is imported.)

The solution here is not to bundle “React” with the library. Instead, the library should have “React” as a peer dependency. Essentially, this means that the library will not bundle “React” with itself. Instead, it will utilize the “React” installed in the project that uses the library. This way, the “React” used by the library and the example will be the same, resolving the issue. To implement this solution, we first need to specify “React” as a peer dependency in the library’s package.json file.

{
    "name": "library",
    "peerDependencies": {
        "react": "^17.0.2"
    }
}

After specifying “React” as a peer dependency in the library’s package.json file, the next step is to instruct our bundler, Webpack, not to bundle “React” with the library. This can be achieved by designating “React” as an external dependency in the Webpack config file.

// webpack.config.js
module.exports = {
    // ...
    externals: {
        react: "react",
    },
};

Hoisting issue

When using a monorepo setup, you may encounter different packages that depend on various versions of React. For instance, if you’re utilizing yarn workspaces, your package.json file in the workspace root might resemble as shown below. This configuration instructs the system to treat all folders under the packages folder as separate projects.

{
    "workspaces": ["packages/*"]
}

Let’s build on the example from the previous section (Bundling issue). In real life, library authors often include usage examples in the same repository as the library, facilitating easier debugging and showcasing various use cases. Therefore, they would have a package.json file at the root specifying the workspaces, as well as in each of the packages, for example, library and example, as shown below. It’s important to note that in this context, dependencies are used instead of peerDependencies. This intentional choice will be clarified later in the explanation.

packages/library/package.json

{
    "name": "library",
    "version": "1.0.0",
    "dependencies": {
        "react": "^17.0.0"
    }
}

packages/example/package.json

{
    "name": "example",
    "dependencies": {
        "react": "^18.0.0",
        "react-dom": "^18.0.0",
        "library": "^1.0.0"
    }
}

(Fig-B: The arrow convention is reverse in this case than Fig-A. The solid arrows point to the dependency imported. )

Most monorepo tools, such as Yarn Workspaces, Lerna, etc., hoist dependencies to the root node_modules folder to reduce the memory footprint. This means that if you have two packages, say package-a and package-b, both depending on the same version of React, only one copy of “react” will be installed in the root node_modules folder, minimizing redundancy. However, challenges arise when versions differ. For instance, if package-a relies on React version 17 and package-b relies on React version 18, the root node_modules will host React version 17. This situation mirrors our current example, where package-a is library, and package-b is example. library imports React version 17. example imports library, but example also imports react-dom from example/node_modules, which, in turn, imports React version 18. But, as we know that react-dom should import the same react module from which hooks were imported and hence causes an unexpected violation of the fundamental rule of hooks. You can see the visual representation above in Fig-B. Also note that yarn workspaces uses symlinks to point to a package which can be used by another project inside the monorepo.

Note that we are using Webpack, and since it utilizes NodeJS under the hood, its module resolution algorithm adheres to the NodeJS specification. In other words, it resolves dependencies from the current node_modules and continues traversing up to the parent node_modules until the dependency is found. If the dependency is not found, it throws an error.

Remember that we used dependencies instead of peerDependencies in the library package. But we didn’t because we wanted to deliberately cause this issue. If we had used peerDependencies then yarn would not have installed react@17, infact it would skip installing this version entirely. And yarn would have installed react@18 in the root node_modules which would be accessed by both the library and the example. So we should remember to use peerDependencies in our packages to avoid this issue.

What if we have another project inside packages that depends on a different version of React, and it is necessary for the package to use dependency instead of peerDependency? Continuing our example, let’s say we want to showcase another use case of the library and name the example example-2. It depends on library, react@17, and react-dom@17. In this case, Yarn would install react@17 in the root node_modules folder and react@18 in example/node_modules, leading to the same issue. Fig-C visually represents the scenario. Solving this hoisting issue using nohoist isn’t effective. The best approach is to sync the versions of React and React-DOM in all the packages used under the monorepo. However, syncing versions can be a tedious task, especially with a large number of packages, and version upgrades might break the code. There must be clever ways to address this issue, but they are beyond the scope of this post.

(Fig-C: Shows that react@17 is installed because it is depended by example and as a result react@18 is installed at example/node_modules. Note we aren’t showing the symlink of library to reduce the complexity of diagram.)

Conclusion

In this post, we saw that the rule of hooks can be violated without using hooks inside a conditional or a function statement. We saw that this can happen if the “React” module instance used by ReactDOM is different from the one used by the library. This can happen if the library bundles “React” with itself. We saw that this can also happen in a monorepo setup where each package uses a different version of “React”. We saw that this can be solved by using “React” as a peer dependency in the library and using peerDependencies in the packages in a monorepo setup. We also saw that this issue can still occur if we have another package which “depends” on a different version of “React”. In this case, one straightforward but not the best solution is to sync the versions of “React” and “React-DOM” in all the packages used under the monorepo. You can access all the code on Github. The first commit contains both the issues described and the next one solves it.

All in all, we tackled an unexpected and cryptic error that can be daunting to solve initially. However, these are the challenges that motivate us to delve deeper and gain a better understanding of the tools and technologies we work with. I hope you enjoyed reading this post. Thank you for your time

🔁 and ❤️ this post on Linkedin | Twitter