React + Lib: How to include a local React UI library component into a React app. Part 2 of 2

React + Lib: How to include a local React UI library component into a React app. Part 2 of 2

We continue discussing a problem when a react app is using a UI widget, developed as an external library, which is not yet published in NPM.

Cover picture by: Image by wirestock on Freepik

The second part of the article, the first part can be found here: React + Lib: How to include a local React UI library component into a React app. Part 1 of 2 (hashnode.dev)

Previously...

In the first part of this article, we started looking for a solution to a problem when an external library, containing UI widgets and components, should be included in a React app.

Using Webpack, we have been able to create a bundle, which then has been included in the app.

However, things didn't go quite the way we wanted, and we have left off on the following error:

Uncaught ReferenceError: React is not defined

We are now taking the quest from this point.

Invisible React

To find a solution to this problem I had to search a lot. Frankly speaking, A LOT! A lot of googling and youtubing, and stack-overflowing, and... well you got the point.

Let's first try to understand what our browser is complaining about:

It turns out that the line the browser is not happy about is the following one:

icon: /*#__PURE__*/React.createElement(_mui_material_Icon__WEBPACK_IMPORTED_MODULE_22__["default"], null, "dashboard"),

This rather cryptic piece of JavaScript is actually what our code has been compiled to by Webpack. Remember bundle creation in the first part? That's the one.

We can see that the method createElement is being applied to the object React, which is supposed to be available globally. However, this is not the case, as one can see by putting the breakpoint there.

So what could be the problem?

If we examine the code of the bundle further, we will find things that are quite strange to my mind. Let's see if createElement is always being called on the React global object. For example, we can find the following piece of code in the bundle:

function BrowserRouter(_ref) {
  ...
  return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0__.createElement(react_router__WEBPACK_IMPORTED_MODULE_2__.Router, {
    basename: basename,
    children: children,
    location: state.location,
    navigationType: state.action,
    navigator: history
  });
}

There we can see that this time the createElement is being called on the object created by Webpack: react__WEBPACK_IMPORTED_MODULE_0__.createElement.

So one can conclude that for some unknown reason, webpack sometimes replaces React by react__WEBPACK_IMPORTED_MODULE_0__, and sometimes leaves it as is!

In the final bundle that runs in the browser, the React is being transpiled into react__WEBPACK_IMPORTED_MODULE_0__, and hence no global variable React exists.

If in our bundle all the instances of React had been transpiled into react__WEBPACK_IMPORTED_MODULE_0__, then we wouldn't have faced this problem. But the world is not perfect...

Ok, that being said, we can define the global variable ourselves, right? Defining global variables is probably the most widespread skill a web developer possesses... How about putting window.React = React in our index.js. Like this:

react-app-plus-local-lib-workspace\my-react-app-abc\src\index.js:

import React from "react";
window.React = React;

This might seem stupid and naive, but let's see if it works:

main.js:452  Uncaught ReferenceError: React is not defined

So far, no luck... And here is why. The above-mentioned definition of the global variable has been transpiled into this:

window.React = (react__WEBPACK_IMPORTED_MODULE_0___default());

Remember earlier, when I said, that any instance of React is being transpiled into Webpack module? That's exactly what it looks like.

As has been said earlier, I had to search a lot to solve this problem and could find neither an explanation for it nor a remedy.

Finally, I stumbled upon the Webpack ProvidePlugin and tried it out like this:

react-app-plus-local-lib-workspace\material-kit-react\build-module.config.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
    ...
    plugins: [
        new webpack.ProvidePlugin({
            React: 'react',
        })
    ],
    ...
};

According to the plugin's doc, this plugin will load the module into a global variable, so that this module could be used without explicitly importing or requiring it.

ProvidePlugin

Automatically load modules instead of having to import or require them everywhere.

If we recompile the bundle and retry the app in the browser, will it work? Believe it or not, yes, it will!

Well, you might have guessed that since otherwise I wouldn't have mentioned ProvidePlugin at all, and probably the whole article wouldn't have been published.

Cool! We do not have this "React is not defined" error anymore. Instead, we are having something else. We will address that later, but first, let's see, what brought us this magic, that made the error go away:

/* provided dependency */ var React = __webpack_require__(/*! react */ "./node_modules/react/index.js");

This is the line that has been added by the Webpack ProvidePlugin. And this definition of React is exactly what we were looking for so desperately.

Strictly speaking, this is not the preferred solution, but more like a workaround for me. The real solution would be to have Webpack consistently transpile all the instances of React variables. But so far I couldn't come up with a better solution, so let's live with this for the time being and continue our quest.

Export the essential - the App

The next problem we'll need to deal with would be the following one:

Indeed, let's look back at how we have created our bundle.

module.exports = {
    mode: "development",
    target: ['web'],
    devtool: "source-map",
    entry: {
        'main': './src/index.js',
    },
    ...
}

The entry point of the bundle is the file index.js, which indeed doesn't export anything at all, let alone the App module. So let's export it!

react-app-plus-local-lib-workspace\material-kit-react\src\index.js

...
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

export default App; // reexport the module App in the end of index.js

We are simply reexporting the module, already imported into index.js.

Another possible solution to exporting the App module would be to create a bundle with another entry point. In our case, this entry point should be src/app/App.js.

Let's explore this way by modifying the bundle config as follows:

module.exports = {
    entry: {
        'main': './src/index.js',
        'app': './src/app/App.js'
    },
    output: {
        ...
        filename: 'material-kit-2-react.[name].lib.js',
        library: 'material-kit-2-react-[name]',     
        ...
    },
    ...
}

and package.json as follows:

{
  ...
  "exports": {
    ".": "./dist/material-kit-2-react.main.lib.js",
    "./app": "./dist/material-kit-2-react.app.lib.js"
  },
  ...
}

If we compile the bundle and look into our ./dist folder, we'll find two new files: ./dist/material-kit-2-react.app.lib.js and ./dist/material-kit-2-react.main.lib.js

└── .
    ├── dist
    │   ├── material-kit-2-react.app.lib.js
    │   ├── material-kit-2-react.app.lib.js.map
    │   ├── material-kit-2-react.main.lib.js
    │   └── material-kit-2-react.main.lib.js.map
    ├── node_modules

These are two bundles that we have created with two different entry points: ./src/index.js and ./src/app/App.js

Then the module App can be imported as follows:

import App from "material-kit-2-react/app";

Thanks to the fact that we have re-exported the App module from the index.js, the App can be imported using the main bundle as well:

import App from "material-kit-2-react";

Handling the dreaded error: "Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component."

I have to admit: even though at the beginning of our quest we stated that we should remove React from the bundle (using the externals section) so that it wouldn't be included twice in the final project, I removed this section from the Webpack config. I did so when I was desperate to find out why React was not found (the problem we discussed in the previous chapter).

The error I'm facing now, the one that has been put at the title of the chapter, is the following:

Uncaught Error: Invalid hook call. 
Hooks can only be called inside of the body of a function component. 
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

So, in our case, the cause of the error is quite clear I guess. It can only happen because of another React library present. Let's see how to remove it.

If we have a look at our bundle - material-kit-2-react.main.lib.js - we'll see that it counts exactly 134501 lines (in any case, in my setup). If we add the externals config back to the Webpack config file like this:

module.exports = {
    ...
    externals: {      
        react: {          
            commonjs: "react",          
            commonjs2: "react",          
            amd: "React",          
            root: "React"      
        },      
        "react-dom": {          
            commonjs: "react-dom",          
            commonjs2: "react-dom",          
            amd: "ReactDOM",          
            root: "ReactDOM"      
        }  
    },
    ...
};

we'll see that the size would change from 134501 to 101168. Hence we conclude that the React and ReactDOM libraries were indeed present in the initial bundle, and now they are not.

Let's see if this would remedy the situation with the React library included twice. We run the server with npm start, and the error is still present.

Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

But why? This time, if React is not coming from our bundle, where the hell it's coming from?

This was the question I was asking myself when I was opening the source tree in the browser. Let's have a look at it:

As we can see, there are indeed several instances of React present:

  • one is coming from the node_modules of our application, this one is expected, thus it's marked in blue

  • but there are still two other instances, marked in red:

    • react in material-kit-react/node_modules

    • react in material-kit-2-react/node_modules

So far, I have no explanation for that. We can assume that it has something to do with how npm creates symbolic links to the adjacent libraries. Unfortunately, I couldn't go any further than this vague assumption. And definitely, that didn't push me forward either.

So, at this point, the only way of advancing in this tangled quest that I saw was ejecting the application from create-react-app framework. Didn't I say that my-react-app-abc was a create-react-app application? Well, if not, I just did.

Taking things under control: ejecting the application

At this point, I think we are hitting the limits of the create-react-app suite, with which the initial application has been created. Create-react-app is a project that has been designed initially to facilitate the creation of a React-based application. But all good things come to an end at some point, and now we see that this simplicity prevents us from going any further...

To resolve the issue with the missing React, the only option I have found is to use a custom Webpack config file, which is apparently not possible with create-react-app suite.

But there is another reason not to use create-react-app longer than initial application creation. Consider this warning that I had during the compilation of my bundle:

One of your dependencies, babel-preset-react-app, is importing the
"@babel/plugin-proposal-private-property-in-object" package without
declaring it in its dependencies. This is currently working because
"@babel/plugin-proposal-private-property-in-object" is already in your
node_modules folder for unrelated reasons, but it may break at any time.

babel-preset-react-app is part of the create-react-app project, which
is not maintianed anymore. It is thus unlikely that this bug will
ever be fixed. Add "@babel/plugin-proposal-private-property-in-object" to
your devDependencies to work around this error. This will make this message
go away.

Probably there is another way of supplying the custom Webpack config file, which doesn't include ejection, but I'm not aware of it. Feel free to propose it in the comments, if you find one!

So are we ejecting? The answer is yes!

cd my-react-app-abc
npm run eject

> my-react-app-abc@1.0.0 eject
> react-scripts eject

NOTE: Create React App 2+ supports TypeScript, Sass, CSS Modules and more without ejecting: https://reactjs.org/blog/2018/10/01/create-react-app-v2.html

? Are you sure you want to eject? This action is permanent. » (y/N)

So cute... It is as if the create-react-app is so fond of us, that it is leaving us a second chance to reconsider our decision! Frankly, I was touched... but my decision has been made :-)

Now that our application is ejected, we can add the Webpack custom config file - webpack.config.js:

const HtmlWebPackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = {
    context: __dirname,
    target: ['web'],
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'main.js',
        publicPath: '/',
    },
    devServer: {
        historyApiFallback: true
    },
    resolve: {
        symlinks: false,
        alias: {
            'react': path.resolve('./node_modules/react'),
            'react-dom': path.resolve('./node_modules/react-dom'),
        }
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: 'babel-loader',
            },
            {
                test: /\.css$/,
                use: ['style-loader', 'css-loader'],
            },
            {
                test: /\.(png|j?g|svg|gif)?$/,
                use: 'file-loader'
            }
        ]
    },
    plugins: [
        new HtmlWebPackPlugin({
            template: path.resolve(__dirname, 'public/index.html'),
            filename: 'index.html'
        })
    ],
    devServer: {
        static: [
            { directory: path.join(__dirname, 'public') },
            { directory: path.join(__dirname, 'node_modules/material-kit-2-react/dist') }
        ],
        compress: false,
        port: 9000,
    }
};

Apart from defining the dev server, this Webpack contains the following lines:

const path = require('path');
module.exports = {
    context: __dirname,
    target: ['web'],
    resolve: {
        symlinks: false,
        alias: {
            'react': path.resolve('./node_modules/react'),
            'react-dom': path.resolve('./node_modules/react-dom'),
        }
    },
};

Since we have removed the React from the bundle, we need to tell our application where React can be found from, once it is required for a dependency.

Let's add the following script to package.json:

  "scripts": {
    ...
    "dev": "webpack-dev-server --mode=development"
  },

With that in place we can run the application as follows:

npm run dev

and we get:

ERROR in ./src/index.js
Module build failed (from ./node_modules/babel-loader/lib/index.js):
Error: Multiple configuration files found. Please remove one:
 - package.json
 - <full path>\my-react-app-abc\.babelrc

Ok, I can do that. Apparently, .babelrc has been left from the create-react-app epoch, so let's remove it too.

Now, that the application is ejected, let's see if it solved our problem with multiple instances of React:

Oh... Much better, indeed!

We have only one instance of React, which is coming from the node_modules of our application, exactly as expected. As far as our library is concerned, it's bringing only the bundle - material-kit-2-react.main.lib.js, which is also as expected.

Ok, so far, so good, let's head forward.

Ok, so, are we done?

Now that the problems we have encountered so far are behind, let's have a look at the current state of our project.

It's like in the Matrix movie Neo is asking, waking up: "Am I dead?", and Morpheus replies "Far from it". Likewise, I'm asking "Are we done?" and replying to myself: "Far from it...".

Solving "Error: useLocation() may be used only in the context of a component."

The analysis of this error took a lot of time. My first guess was related to the App component, being executed outside of the BrowserRouter component, during the import.

import App from "material-kit-2-react/app";
...
console.log('before rendering');
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

So I added the console log to see whether the error would happen before or after the console logging, and it turned out to be before. Hence, the import is not executing the code of the App component. The code of the App component is executed only when it's called inside the BrowserRouter.

Further investigation showed (it took me quite some time to find out) that the error was related to the react-router-dom library, present twice. This turned out to be very similar to the problem with the react library when it was present twice.

However, in the case of the "Invalid hook call" error, which explicitly stated that the problem was coming from multiple React instances, this error was quite silent about the potential cause. Instead, it was complaining about the component that is being executed outside the <Router>, which, obviously, is not the case.

Apparently, due to the double react-router-dom library, one of the instances has not been initialized properly, and the App component for some reason has been executing inside this non-initialized context. But that's only a guess, I didn't investigate the problem any deeper.

Anyway, to clean up the second instance of react-router-dom, I have used the same pattern we used to exclude React and ReactDOM from the bundle, using the externals config option. Finally, the "externals" section of my bundle Webpack config looks like this:

...
module.exports = {
    ...
    externals: {      
        "react": {
            commonjs: "react",
            commonjs2: "react",
            amd: "React",
            root: "React"
        },
        "react-dom": {
            commonjs: "react-dom",
            commonjs2: "react-dom",
            amd: "ReactDOM",
            root: "ReactDOM"
        },
        "react-router-dom": {
            commonjs: "react-router-dom",
            commonjs2: "react-router-dom",
            amd: "ReactRouterDom",
            root: "ReactRouterDom"
        }  
    },
    ...
};

These excluded libraries should all be matched by the corresponding aliases on the application level:

my-react-app-abc-ejected\webpack.config.js

module.exports = {
    ...
    resolve: {
        symlinks: false,
        alias: {
            'react': path.resolve('./node_modules/react'),
            'react-dom': path.resolve('./node_modules/react-dom'),
            'react-router-dom': path.resolve('./node_modules/react-router-dom')
        }
    },
    ...
};

This is important, as otherwise, the bundle won't be working properly, since it will have a hard time finding the libraries it depends on. I have encountered quite strange behavior if the aliases were not present.

One would expect to see errors similar to "React is not defined", but what was actually happening, is that the node_modules folder of the bundle has been included in the sources, similar to what has been shown for the react library.

Solving: "404 Not Found: localhost:9000/dist/xxx.jpg"

The next problem was related to the missing jpg images, that were supposed to come from the compiled bundle.

I have added the following section to my dev-server config to serve the required images from the bundle:

{ directory: path.join(__dirname, 'node_modules/material-kit-2-react/dist') }

module.exports = {
    ...
    devServer: {
        static: [
            { directory: path.join(__dirname, 'public') },
            { directory: path.join(__dirname, 'node_modules/material-kit-2-react/dist') }
        ],
        compress: false,
        port: 9000,
    }
};

This was not enough, however, since the requested URL has the "/dist/" path section in it: localhost:9000/**dist/**889630297390d27e8df..

It turned out that this path section was added due to the output.publicPath option of the bundle Webpack config:

module.exports = {
    output: {
        ...
        publicPath: '/dist/',  // <-- to be removed
        ...
    },
};

Due to the presence of this config option, all the links inside the components have been compiled taking it into account. So I have removed it, and finally...

YOUPPPPPIIII!!!!!! It worked!!!!

Behold!

This is finally the result this long quest was all about!

Conclusion

Our goal was to include a local library with UI widgets in a React app that I'm working on. The problem was related to the fact that the component library was not available as an NPM package, but rather as a standalone React app.

This component library was a React theme developed by Creative Tim: Material Kit 2 React: Free MUI & ReactJS Kit @ Creative Tim (creative-tim.com)

The idea was to keep the code and the theme separate so that the source of my React App would remain slim and concise.

Let's summarize our checklist:

TaskStatus
Create a React App which would include an external, locally available, library with React UI componentsDONE
Understand better the mechanism of bundle creation with WebpackDONE
Find out that React is much trickier than it looks!DONE
Find myself in situations when the only viable option is to hit my head against the wallDONE

Indeed, the task appeared to be harder than it looked, when I first started working on my React App.

Finally, we have ended up achieving the desired result, even though some points remain unclear.

Let's highlight the important points:

  • The externals section of the Webpack config should be used to exclude the libraries, already present in the hosting application, such as React, ReactDOM and ReactDomRouter.

  • We had to use Webpack ProvidePlugin to declare the React variable globally

  • As we have seen, React is really allergic to its libraries being present twice in the JS code loaded in the browser. This relates not only to React itself but also to ReactDomRouter, which is less evident.

  • When working with React, using create-react-app is probably not the best option. At some point, we had to eject our application to provide our custom Webpack config with its dev server.

My background as a software engineer comes from the Java world, where Maven is available to take care of our dependencies. NPM provides features similar to that of Maven. For example, peerDependencies in NPM are quite equivalent to <scope>provide</scope> in Maven.

And yes, using Maven you can also end up with some classes being present in the classpath twice with different versions. Which would also lead to some disastrous results.

Still, the two systems are fundamentally different. In particular, such a thing as classpath doesn't exist in the JS world, which is probably related inherently to the fact that JS scripts are loaded from HTML files.

Comparing two systems - NPM and Maven - I would say that the fundamental difference is related to the fact that bundles in NPM are loaded without analyzing their dependencies. So each library should be bundled such that it would be compatible with the application it will be used in.

All in all, it was a fun experience :-)

Here are the two repositories on GibHub demonstrating the end result:

UPD: here is the

NOTES

  • I had to restart my dev server on my-react-app-abc every time the bundle was recompiled, or the configuration changed, to have clear results in the browser

  • Likewise, I had to use the hard-reload option on the browser to make sure that the latest bundle is taken into account

  • NodeJS version: v18.16.0

  • NPM version: 9.6.5

  • React version: 18.2.0

  • Webpack version: 5.89.0

References: