Introduction

Have you ever had to manually copy components from one project to another? It's a hassle, right? I felt the same while working with web resources for Dynamics CRM using React. Lots of web resources had common components, so copying them over and over just didn't make sense. So, I made these UI components shareable, and I want to share how I did it with you.

Before I started making the UI library, I thought it would be easy. But, as they say, the devil is in the details. The hardest part was creating the build files. So, that's what I'm going to focus on in this guide. Let's get started!


Needs:

  1. We'll use React as our JavaScript framework.
  2. TypeScript will help us check types.
  3. We'll base our library on the Material UI library.
  4. The system should allow imports from subfolders.

Creating UI Library

We're not going to use any bundlers like Rollup, Webpack, etc. We want to delegate this process to our React app. Also, we will create a npm package that supports only ESM modules.

So, let's initialize our library using npm.

Terminal

npm init -y

This should generate a 'package.json' file with the following code:

package.json

{
  "name": "@uds-crm/ui",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

Project Directory Structure

src
├── Checkbox
│   ├── Checkbox.tsx
│   ├── Checkbox.styled.tsx
│   ├── icons.tsx
│   └── index.ts
├── Button
│   ├── Button.tsx
│   ├── Button.styled.tsx
│   └── index.ts
├── QuickFilter
│   ├── QuickFilter.tsx
│   └── index.ts
├── QuickFilterItem
│   ├── QuickFilterItem.tsx
│   └── index.ts
├── Radio
│   ├── Radio.tsx
│   ├── Radio.styled.tsx
│   └── index.ts
…
├── index.ts

Next, we install all the necessary packages for our development as dev dependencies:

Terminal

npm install @babel/cli @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @babel/plugin-proposal-class-properties @babel/plugin-proposal-private-methods @babel/plugin-proposal-private-property-in-object @babel/plugin-proposal-object-rest-spread typescript @babel/plugin-transform-runtime tslib typescript --save-dev

Initialize 'tsconfig.json' in the root of our project:

tsconfig.json

{
  "compilerOptions": {
    "module": "esnext",
    "target": "es5",
    "lib": ["es2020", "dom"],
    "jsx": "preserve",
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "experimentalDecorators": true,
    "baseUrl": "./",
    "allowSyntheticDefaultImports": true,
    "noErrorTruncation": false,
    "allowJs": true,
    "outDir": "dist",
    "skipLibCheck": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "esModuleInterop": true,
    "types": ["node", "react", "react-is/next"]
  },
  "include": ["src/**/*"],
  "exclude": ["**/.*/", "**/dist", "**/node_modules"]
}

And 'babel.config.js'

babel.config.js

module.exports = function getBabelConfig(api) {
  const presets = [
    [
      "@babel/preset-env",
      {
        bugfixes: true,
        modules: false,
      },
    ],
    [
      "@babel/preset-react",
      {
        runtime: "automatic",
      },
    ],
    "@babel/preset-typescript",
  ];

  const plugins = [
    ["@babel/plugin-proposal-class-properties", { loose: true }],
    ["@babel/plugin-proposal-private-methods", { loose: true }],
    ["@babel/plugin-proposal-private-property-in-object", { loose: true }],
    ["@babel/plugin-proposal-object-rest-spread", { loose: true }],
    [
      "@babel/plugin-transform-runtime",
      {
        useESModules: true,
        // any package needs to declare 7.4.4 as a runtime dependency. default is ^7.0.0
        version: "^7.4.4",
      },
    ],
  ];

  api.cache(true);

  return {
    assumptions: {
      noDocumentAll: true,
    },
    presets,
    plugins,
    ignore: [/@babel[\\|/runtime]/],
  };
};

We also need to install react and react-dom for development purposes and add necessary dependencies to peerDependencies of our project.

Terminal

npm install react react-dom --save-dev

"peerDependencies": {
    "@emotion/react": "^11.5.0",
    "@emotion/styled": "^11.3.0",
    "@mui/material": "^5.11.1",
    "@types/react": "^17.0.0 || ^18.0.0",
    "react": "^17.0.0 || ^18.0.0",
    "react-dom": "^17.0.0 || ^18.0.0"
  },
  "peerDependenciesMeta": {
    "@types/react": {
      "optional": true
    }
  }

Our 'package.json' file should now look like this:

package.json

{
  "name": "@uds-crm/ui",
  "version": "1.0.0",
  "main": "index.js",
  "license": "ISC",
  "peerDependencies": {
    "@emotion/react": "^11.5.0",
    "@emotion/styled": "^11.3.0",
    "@mui/material": "^5.11.1",
    "@types/react": "^17.0.0 || ^18.0.0",
    "react": "^17.0.0 || ^18.0.0",
    "react-dom": "^17.0.0 || ^18.0.0"
  },
  "peerDependenciesMeta": {
    "@types/react": {
      "optional": true
    }
  },
  "devDependencies": {
    "@babel/cli": "^7.22.9",
    "@babel/core": "^7.22.9",
    "@babel/plugin-proposal-class-properties": "^7.18.6",
    "@babel/plugin-proposal-object-rest-spread": "^7.20.7",
    "@babel/plugin-proposal-private-methods": "^7.18.6",
    "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
    "@babel/plugin-transform-runtime": "^7.22.9",
    "@babel/preset-env": "^7.22.9",
    "@babel/preset-react": "^7.22.5",
    "@babel/preset-typescript": "^7.22.5",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "tslib": "^2.6.1",
    "typescript": "^5.1.6"
  }
}

You can learn more about each dependency in the npm package description.
Next, we will tackle the implementation of the subfolder import feature. 
What, actually, subfolder import is and how it can help us to avoid issues like this one?
The subfolder import process involves importing individual components directly from their respective subfolders, as shown below:

import Checkbox from "@uds-crm/ui/Checkbox";

This approach is different from importing the entire library:

import { Checkbox } from "@uds-crm/ui";

Implementing subfolder import can significantly reduce our bundle size, helping us avoid potential issues linked to performance and loading times.
To achieve this, I discovered a handy trick from browsing the Material UI GitHub: we simply need to place a 'package.json' file into each component folder when we build our application.
Now, let's create our build file. Before we do that, I suggest we establish a 'script' folder where we can house our build helper scripts. This will help keep our project neat and organized.
This script compiles our source code using babel.

scripts/build.mjs

 1. // scripts/build.mjs
 2.  
 3. import childProcess from "child_process";
 4. import path from "path";
 5. import { promisify } from "util";
 6.  
 7. const exec = promisify(childProcess.exec);
 8.  
 9. async function run() {
10.   const babelConfigPath = path.resolve(process.cwd(), "babel.config.js");
11.   const srcDir = path.resolve("./src");
12.   const extensions = [".js", ".ts", ".tsx"];
13.  
14.   // list of files that won't be processed by babel
15.   const ignore = [
16.     "**/*.test.js",
17.     "**/*.test.ts",
18.     "**/*.test.tsx",
19.     "**/*.spec.ts",
20.     "**/*.spec.tsx",
21.     "**/*.d.ts",
22.   ];
23.  
24.   const outDir = path.resolve("./dist");
25.  
26.   // arguments for running babel cli
27.   const babelArgs = [
28.     "--config-file",
29.     babelConfigPath,
30.     "--extensions",
31.     `"${extensions.join(",")}"`,
32.     srcDir,
33.     "--out-dir",
34.     outDir,
35.     "--ignore",
36.     // Need to put these patterns in quotes otherwise they might be evaluated by the used terminal.
37.     `"${ignore.join('","')}"`,
38.   ];
39.  
40.   const command = ["npx babel", ...babelArgs].join(" ");
41.  
42.   const { stderr } = await exec(command);
43.   if (stderr) {
44.     throw new Error(`'${command}' failed with \n${stderr}`);
45.   }
46. }
47.  
48. run();

Now, we need to create a script that will help us generate our 'package.json' file and place it directly into each component's folder. For this purpose, we need to install additional npm packages as dev dependencies.

Terminal

npm install fast-glob fs-extra --save-dev

scripts/copyFiles.js

 1. // scripts/copyFiles.js
  2.  
  3. const glob = require("fast-glob");
  4. const fse = require("fs-extra");
  5. const path = require("path");
  6.  
  7. const packagePath = process.cwd();
  8. const buildPath = path.join(packagePath, "./dist");
  9. const srcPath = path.join(packagePath, "./src");
 10.  
 11. async function createPackageFile() {
 12.   const packageData = await fse.readFile(
 13.     path.resolve(packagePath, "./package.json"),
 14.     "utf-8"
 15.   );
 16.   const { scripts, devDependencies, ...otherPackageData } =
 17.     JSON.parse(packageData);
 18.  
 19.   const newPackageData = {
 20.     ...otherPackageData,
 21.     private: true,
 22.     main: "./index.js",
 23.     module: "./index.js",
 24.   };
 25.  
 26.   const typeDefinitionsFilePath = path.resolve(buildPath, "./index.d.ts");
 27.   if (await fse.pathExists(typeDefinitionsFilePath)) {
 28.     newPackageData.types = "./index.d.ts";
 29.   }
 30.  
 31.   const targetPath = path.resolve(buildPath, "./package.json");
 32.  
 33.   await fse.writeFile(
 34.     targetPath,
 35.     JSON.stringify(newPackageData, null, 2),
 36.     "utf8"
 37.   );
 38.   console.log(`Created package.json in ${targetPath}`);
 39.  
 40.   return newPackageData;
 41. }
 42.  
 43. async function createModulePackages({ from, to }) {
 44.   const directoryPackages = glob
 45.     .sync("*/index.{js,ts,tsx}", { cwd: from })
 46.     .map(path.dirname);
 47.  
 48.   await Promise.all(
 49.     directoryPackages.map(async (directoryPackage) => {
 50.       const packageJsonPath = path.join(to, directoryPackage, "package.json");
 51.  
 52.       const packageJson = {
 53.         sideEffects: false,
 54.         main: "../index.js",
 55.         module: "./index.js",
 56.         types: "./index.d.ts",
 57.       };
 58.  
 59.       const [typingsEntryExist, moduleEntryExists, mainEntryExists] =
 60.         await Promise.all([
 61.           fse.pathExists(
 62.             path.resolve(path.dirname(packageJsonPath), packageJson.types)
 63.           ),
 64.           fse.pathExists(
 65.             path.resolve(path.dirname(packageJsonPath), packageJson.module)
 66.           ),
 67.           fse.pathExists(
 68.             path.resolve(path.dirname(packageJsonPath), packageJson.main)
 69.           ),
 70.           fse.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)),
 71.         ]);
 72.  
 73.       const manifestErrorMessages = [];
 74.       if (!typingsEntryExist) {
 75.         manifestErrorMessages.push(
 76.           `'types' entry '${packageJson.types}' does not exist`
 77.         );
 78.       }
 79.       if (!moduleEntryExists) {
 80.         manifestErrorMessages.push(
 81.           `'module' entry '${packageJson.module}' does not exist`
 82.         );
 83.       }
 84.       if (!mainEntryExists) {
 85.         manifestErrorMessages.push(
 86.           `'main' entry '${packageJson.main}' does not exist`
 87.         );
 88.       }
 89.       if (manifestErrorMessages.length > 0) {
 90.         throw new Error(
 91.           `${packageJsonPath}:\n${manifestErrorMessages.join("\n")}`
 92.         );
 93.       }
 94.  
 95.       return packageJsonPath;
 96.     })
 97.   );
 98. }
 99.  
100. async function run() {
101.   try {
102.     await createPackageFile();
103.     await createModulePackages({ from: srcPath, to: buildPath });
104.   } catch (err) {
105.     console.error(err);
106.  
107.     process.exit(1);
108.   }
109. }
110.  
111. run();

Our scripts are ready, and now we can add them to our 'package.json' file.

package.json

"scripts": {
    "build": "node scripts/build.mjs && npm run typescript",
    "postbuild": "node ./scripts/copyFiles.js",
    "typescript": "npx tsc",  
}

Let's explore what happens when we execute the 'npm run build' command in your terminal.

First, the 'scripts/build.mjs' script is executed. This script compiles our files from the 'src' folder and places them into the 'dist' folder, where our production-ready files will be stored. Then, the 'npx tsc' command is run to generate a 'd.ts' file for our components. Immediately after that, the 'scripts/copyFiles.js' script is launched, which creates a 'package.json' file for each folder and establishes the correct entry point.

Last but not least, we need to clear our 'dist' folder before every build process. To accomplish this, we'll add an additional 'npm' package, 'rimraf', and finally add its execution command to our 'package.json' file.

Our 'package.json' file now looks like this:

package.json

{
  "name": "@uds-crm/ui",
  "version": "1.0.0",
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "node scripts/build.mjs && npm run typescript",
    "postbuild": "node ./scripts/copyFiles.js",
    "typescript": "npx tsc"
  },
  "license": "ISC",
  "peerDependencies": {
    "@emotion/react": "^11.5.0",
    "@emotion/styled": "^11.3.0",
    "@mui/material": "^5.11.1",
    "@types/react": "^17.0.0 || ^18.0.0",
    "react": "^17.0.0 || ^18.0.0",
    "react-dom": "^17.0.0 || ^18.0.0"
  },
  "peerDependenciesMeta": {
    "@types/react": {
      "optional": true
    }
  },
  "devDependencies": {
    "@babel/cli": "^7.21.0",
    "@babel/core": "^7.21.4",
    "@babel/plugin-proposal-class-properties": "^7.18.6",
    "@babel/plugin-proposal-object-rest-spread": "^7.20.7",
    "@babel/plugin-proposal-private-methods": "^7.18.6",
    "@babel/plugin-proposal-private-property-in-object": "^7.21.0",
    "@babel/plugin-transform-runtime": "^7.21.4",
    "@babel/preset-env": "^7.21.4",
    "@babel/preset-react": "^7.18.6",
    "@babel/preset-typescript": "^7.21.4",
    "@types/node": "^18.16.0",
    "fast-glob": "^3.2.12",
    "fs-extra": "^11.1.1",
    "prop-types": "^15.8.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "rimraf": "^4.4.1",
    "storybook": "^7.0.7",
    "tslib": "^2.5.0",
    "typescript": "^5.0.3"
  }
}

And our folder structure looks like this:

Now, you can publish the 'dist' folder wherever you want (npm registry, GitHub, etc.).

Conclusion

So, today we've learned how to create a React UI library and write a configuration for subfolder imports. I hope it was useful for you and that you'll use this approach within your projects. Good luck!

If you have any questions, please do not hesitate to get in touch with us.