'Resolving multiple versions of React in component library built with Webpack and Storybook

I'm trying to build a React component library built on top of MUI and using Storybook and TypeScript. Because Storybook (which uses create-react-app) is based off of Webpack, and because my component library includes SASS files that can't be compiled using tsc, I'm using Webpack to build the bundle. Then I'm importing the component library into another React application, with its own version of React.

To test this out, I've built a vanilla TypeScript create-react-app demo app, and am importing my library from a specific branch on my hosted Github repo. When I try to include a component from the library, the TS types show up correctly, but the app throws one of these errors, pointing to a use of hooks in the underlying MUI library. This seems overwhelmingly likely to me to be a competing-React-versions issue, rather than a hooks issue, because A) MUI works, and B) the components work within the Storybook.

Here's the basic structure of my component library.

I put app dependencies unrelated to build (React, MUI, MUI dependencies) in peerDependencies, so that they aren't duplicated.

Component Library

package.json

{
  "name": "my-component-library",
  "version": "0.1.0",
  "private": true,
  "license": "MIT",
  "main": "dist/index.js",
  "types": "dist/types/index.d.ts",
  "dependencies": {
    "react-scripts": "5.0.0",
    "web-vitals": "^1.0.1"
  },
  "scripts": {
    "build": "webpack"
    // ...
  },
  // ... Lotsa devDependencies
  "peerDependencies": {
    "@emotion/react": "^11.7.1",
    "@emotion/styled": "^11.6.0",
    "@mui/icons-material": "^5.4.1",
    "@mui/material": "^5.4.1",
    "classnames": "^2.3.1",
    "lodash": "^4.17.21",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-router-dom": "^6.2.1"
  }
}

webpack.config.js

const path = require("path");

module.exports = {
  entry: "./src/index.ts",
  devtool: "source-map",
  output: {
    filename: "index.js",
    path: path.resolve(__dirname, "dist"),
    library: "MyComponentLibrary",
    libraryTarget: "umd",
    clean: true,
  },
  mode: "development",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: { configFile: "tsconfig.build.json" },
        exclude: /node_modules/,
      },
      {
        test: /\.s[ac]ss$/i,
        use: ["style-loader", "css-loader", "sass-loader"],
      },
    ],
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"]
  },
  optimization: {
    minimize: false,
  },
  target: "node",
};

tsconfig.build.json

{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": "src",
    "declaration": true,
    "esModuleInterop": true,
    "emitDeclarationOnly": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "lib": ["es6", "dom"],
    "module": "esnext",
    "moduleResolution": "node",
    "noEmit": false,
    "noFallthroughCasesInSwitch": true,
    "noImplicitAny": true,
    "noImplicitThis": true,
    "outDir": "dist/types",
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "strictNullChecks": true,
    "sourceMap": true,
    "target": "es5"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.tsx", "src/**/*.stories.tsx", "src/__mocks__"]
}

When I run npm run build, Webpack compiles the files into dist/index.js correctly, and the types are put (with a bunch of extraneous stuff I'd like to sort out) into dist/types.

Demo App

Then, my consuming demo app is a basic CRA application, with the TypeScript template. Here's the key components of the package file:

package.json

{
  "name": "cld",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@types/jest": "^27.4.1",
    "@types/node": "^16.11.27",
    "@types/react": "^17.0.1",
    "@types/react-dom": "^17.0.1",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-scripts": "5.0.1",
    "typescript": "^4.6.3",
    "web-vitals": "^2.1.4",

    // Note the Github require
    "my-component-library": "github:my-private-handle/component-library#branch"
  },
}

In my App.tsx file:

App.tsx

import React from "react";
import { Button } from "my-component-library";

function App() {
  return (
    <div className="App">
      <Button>Hello</Button>
    </div>
  );
}

export default App;

If I hover over Button, I see the component documentation, and if I give it bad props, TS shows the errors. But if I run the app, the file throws the above error because of mismatched React versions (I think).

However, there appears to correctly be only one version each of React and React DOM -- the one defined by the consuming app.

npm list react react-dom
[email protected] /Users/sasha/code/cld
├─┬ [email protected]
│ └── [email protected] deduped
├─┬ [email protected]
│ └── [email protected] deduped
├── [email protected]
└─┬ [email protected] (git+ssh://[email protected]/private-handle/component-library.git#09b5db39d8aa8a76f7c1fecacdc3afaecd57812e)
  ├─┬ @emotion/[email protected]
  │ └── [email protected] deduped
  ├─┬ @emotion/[email protected]
  │ └── [email protected] deduped
  ├─┬ @mui/[email protected]
  │ └── [email protected] deduped
  ├─┬ @mui/[email protected]
  │ ├─┬ @mui/[email protected]
  │ │ ├── [email protected] deduped
  │ │ └── [email protected] deduped
  │ ├─┬ @mui/[email protected]
  │ │ ├─┬ @mui/[email protected]
  │ │ │ └── [email protected] deduped
  │ │ ├─┬ @mui/[email protected]
  │ │ │ └── [email protected] deduped
  │ │ └── [email protected] deduped
  │ ├─┬ @mui/[email protected]
  │ │ └── [email protected] deduped
  │ ├── [email protected] deduped
  │ ├─┬ [email protected]
  │ │ ├── [email protected] deduped
  │ │ └── [email protected] deduped
  │ └── [email protected] deduped
  ├── [email protected] deduped
  ├─┬ [email protected]
  │ ├── [email protected] deduped
  │ ├─┬ [email protected]
  │ │ └── [email protected] deduped
  │ └── [email protected] deduped
  └── [email protected] deduped

UPDATE: Following the debugging recommendations here, I did this in the receiving/demo app:

// Add this in node_modules/react-dom/index.js
window.React1 = require('react');

// Add this in your component file
require('react-dom');
window.React2 = require('react');
console.log(window.React1 === window.React2);

As of the latest run, this returns false, because window.React1 is undefined -- and the local node_modules/react-dom/index.js file appears not to be hit despite the require("react-dom"). The result of that require is a ReactDOM object, so I'm not sure where it's coming from. Very strange, but probably the root of the issue.


UPDATE: I've validated that this is the only issue in the repo. Components that aren't built on MUI and don't use hooks (directly, or through MUI) render just fine. Utils exported from the same library work perfectly, as do the types. It's just that any component that uses MUI or another library using React hooks throws an error. This makes no sense to me, since there's one single version of React in the app.



Solution 1:[1]

The problem boiled down to this: Webpack was building a bundle with React, ReactDOM, and React Router. The versions of React etc within the bundle conflicted with the versions in the "receiving" app, even though npm list didn't show multiple versions.

To fix this, I used the externals functionality in my webpack config:

{
  ...
  externals: {
    'react': 'react',
    'react-dom': 'react-dom',
    'react-router-dom': 'react-router-dom',
    'react-router': 'react-router'
  }
}

This then builds to a dist folder but does not include React etc in the build, instead expecting it to be installed in the receiving app. That build bundle is published. Currently, I'm just pulling it in from Github, but this should work released to NPM or a private package repository.

The consuming/receiving app can then import the files/types just fine, without clashes.

Solution 2:[2]

I had the exact same issue when developing React component library.

The only solution that consistently worked for me was using relative npm link from the example folder to the webpack folder and forcing this specific version on the webpack project. Reread the last lines of this section and try it out. Note that for me only npm link worked(yarn link did not)
Here's a real project that uses this method, and a script that handles it https://github.com/Eliav2/react-xarrows/blob/197326ccb032d5b5a3a7e18def596abac7327b05/examples/package.json#L34. Make sure to adjust the relative path to your case

(sorry for the format, answering from mobile device)

Another option is using monorepo,then a single copy of react will be installed and you could import your Lib from the examples project

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Sasha
Solution 2 Eliav Louski