22

I am creating a shareable React component library.

The library contains many components but the end user may only need to use a few of them.

When you bundle code with Webpack (or Parcel or Rollup) it creates one single file with all the code.

For performance reasons I do not want to all that code to be downloaded by the browser unless it is actually used. Am I right in thinking that I should not bundle the components? Should the bundling be left to the consumer of the components? Do I leave anything else to the consumer of the components? Do I just transpile the JSX and that's it?

If the same repo contains lots of different components, what should be in main.js?

Ollie Williams
  • 1,996
  • 3
  • 25
  • 41
  • 1
    If I understood your question correctly you are looking for a approach like this [one](https://github.com/ant-design/ant-design) take a look at their source code and you'll see that they export all the components as well as individual ones and when a client app uses their components (and imports individual components instead of entire module) webpack will pull only those files that were `imported` in the code thus decreasing the bundle size. – Ed C Jan 24 '20 at 18:35

5 Answers5

34

This is an extremely long answer because this question deserves an extremely long and detailed answer as the "best practice" way is more complicated than just a few-line response.

I've maintained our in-house libraries for 3.5+ years in that time I've settled on two ways I think libraries should be bundled the trade-offs depend on how big your library is and personally we compile both ways to please both subsets of consumers.

Method 1: Create an index.ts file with everything you want to be exposed exported and target rollup at this file as its input. Bundle your entire library into a single index.js file and index.css file; With external dependencies inherited from the consumer project to avoid duplication of library code. (gist included at bottom of example config)

  • Pros: Easy to consume as project consumers can import everything from the root relative library path import { Foo, Bar } from "library"
  • Cons: This will never be tree shakable, and before people say to do this with ESM and it will be tree shakeable. NextJS doesn't support ESM at this current stage and neither do a lot of project setups that's why it's still a good idea to compile this build to just CJS. If someone imports 1 of your components they will get all the CSS and all the javascript for all your components.

Method 2: This is for advanced users: Create a new file for every export and use rollup-plugin-multi-input with the option "preserveModules: true" depending on how what CSS system you're using your also need to make sure that your CSS is NOT merged into a single file but that each CSS file requires(".css") statement is left inside the output file after rollup and that CSS file exists.

  • Pros: When users import { Foo } from "library/dist/foo" they will only get the code for Foo, and the CSS for Foo, and nothing more.
  • Cons: This setup involves the consumer having to handle node_modules require(".css") statements in their build configuration with NextJS this is done with next-transpile-modules npm package.
  • Caveat: We use our own babel plugin you can find it here: https://www.npmjs.com/package/babel-plugin-qubic to allow people to import { Foo, Bar } from "library" and then with babel transform it to...
import { Foo } from "library/dist/export/foo"
import { Bar } from "library/dist/export/bar"

We have multiple rollup configurations where we actually use both methods; so for library consumers who don't care for tree shaking can just do "Foo from "library" and import the single CSS file, and for library consumers who do care for tree shaking and only using critical CSS they can just turn on our babel plugin.

Rollup guide for best practice:

whether you are using typescript or not ALWAYS build with "rollup-plugin-babel": "5.0.0-alpha.1" Make sure your .babelrc looks like this.

{
  "presets": [
    ["@babel/preset-env", {
      "targets": {"chrome": "58", "ie": "11"},
      "useBuiltIns": false
    }],
    "@babel/preset-react",
    "@babel/preset-typescript"
  ],
  "plugins": [
    ["@babel/plugin-transform-runtime", {
      "absoluteRuntime": false,
      "corejs": false,
      "helpers": true,
      "regenerator": true,
      "useESModules": false,
      "version": "^7.8.3"
    }],
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-transform-classes",
    ["@babel/plugin-proposal-optional-chaining", {
      "loose": true
    }]
  ]
}

And with the babel plugin in rollup looking like this...

        babel({
            babelHelpers: "runtime",
            extensions,
            include: ["src/**/*"],
            exclude: "node_modules/**",
            babelrc: true
        }),

And your package.json looking ATLEAST like this:

    "dependencies": {
        "@babel/runtime": "^7.8.3",
        "react": "^16.10.2",
        "react-dom": "^16.10.2",
        "regenerator-runtime": "^0.13.3"
    },
    "peerDependencies": {
        "react": "^16.12.0",
        "react-dom": "^16.12.0",
    }

And finally your externals in rollup looking ATLEAST like this.

const makeExternalPredicate = externalArr => {
    if (externalArr.length === 0) return () => false;
    return id => new RegExp(`^(${externalArr.join('|')})($|/)`).test(id);
};

//... rest of rollup config above external.
    external: makeExternalPredicate(Object.keys(pkg.peerDependencies || {}).concat(Object.keys(pkg.dependencies || {}))),
// rest of rollup config below external.

Why?

  • This will bundle your shit to automatically to inherit react/react-dom and your other peer/external dependencies from the consumer project meaning they won't be duplicated in your bundle.
  • This will bundle to ES5
  • This will automatically require("..") in all the babel helper functions for objectSpread, classes, etc FROM the consumer project which will wipe another 15-25KB from your bundle size and mean that the helper functions for objectSpread won't be duplicated in your library output + the consuming projects bundled output.
  • Async functions will still work
  • externals will match anything that starts with that peer-dependency suffix i.e babel-helpers will match external for babel-helpers/helpers/object-spread

Finally here is a gist for an example single index.js file output rollup config file. https://gist.github.com/ShanonJackson/deb65ebf5b2094b3eac6141b9c25a0e3 Where the target src/export/index.ts looks like this...

export { Button } from "../components/Button/Button";
export * from "../components/Button/Button.styles";

export { Checkbox } from "../components/Checkbox/Checkbox";
export * from "../components/Checkbox/Checkbox.styles";

export { DatePicker } from "../components/DateTimePicker/DatePicker/DatePicker";
export { TimePicker } from "../components/DateTimePicker/TimePicker/TimePicker";
export { DayPicker } from "../components/DayPicker/DayPicker";
// etc etc etc

Let me know if you experience any problems with babel, rollup, or have any questions about bundling/libraries.

Arman
  • 641
  • 4
  • 12
Shanon Jackson
  • 5,873
  • 1
  • 19
  • 39
  • I am new to this topic, but why do we have to bundle the library in the first place? Can't we just run babel/postcss whatever to transpile the sources then publish it as a standard npm module? – Katona Nov 30 '20 at 15:56
6

When you bundle code with Webpack (or Parcel or Rollup) it creates one single file with all the code.

For performance reasons I do not want to all that code to be downloaded by the browser unless it is actually used

It's possible to have separate files generated for each component. Webpack has such ability by defining multiple entries and outputs. Let's say you have the following structure of a project

- my-cool-react-components
  - src // Folder contains all source code
    - index.js
    - componentA.js
    - componentB.js
    - ...
  - lib // Folder is generated when build
    - index.js // Contains components all together
    - componentA.js
    - componentB.js
    - ...

Webpack file would look something like this

const path = require('path');

module.exports = {
  entry: {
    index: './src/index.js',
    componentA: './src/componentA.js',
    componentB: './src/componentB.js',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'lib'),
  },
};

More info on "code splitting" is here in Webpack docs

If the same repo contains lots of different components, what should be in main.js?

There is a single field in package.json file named main, it's good to put its value lib/index.js according to the project structure above. And in index.js file have all components exported. In case consumer wants to use single component it's reachable by simply doing

const componentX = require('my-cool-react-components/lib/componentX');

Am I right in thinking that I should not bundle the components? Should the bundling be left to the consumer of the components? Do I leave anything else to the consumer of the components? Do I just transpile the JSX and that's it?

Well, it's up to you. I've found that some React libraries are published in original way, others - are in bundled way. If you need some build process, then define it and export bundled version.

Hope, all your questions are answered :)

Community
  • 1
  • 1
Rashad Ibrahimov
  • 3,279
  • 2
  • 18
  • 39
  • Thanks for the response. I don't want to have to update my Webpack config every time I add a new component, as in your example. "it's up to you. I've found that some React libraries are published in original way, others - are in bundled way." This is proving not to be the case. Create React App worked with my unbundled components OK, but Next JS is throwing an error and clearly only works with bundled components, taking the decision out of my hands. – Ollie Williams Jan 29 '20 at 15:24
  • I've tried my best to research :) "I don't want to have to update my Webpack config every time I add a new component" - possible to use some glob-wildcard to not list all components, it solves the problem of updating webpack config for every new component. "Next JS is throwing an error" - well, then bundle your package :) obviously raw package would work if only included into bundling from consumer project. Bundled version will work 100%. – Rashad Ibrahimov Jan 29 '20 at 17:19
4

You can split your components like lodash is doing for their methods.

What you probably have is separate components that you could allow importing separately or through the main component.

Then the consumer could import the whole package

import {MyComponent} from 'my-components';

or its individual parts

import MyComponent from 'my-components/my-component';

Consumers will create their own bundles based on the components they import. That should prevent your whole bundle being downloaded.

Bojan Bedrač
  • 846
  • 1
  • 7
  • 10
2

You should take a look at Bit, I think this is a good solution to share, reuse and visualize components.

It is very easy to setup. You can install your bit library or just a component with:

npm i @bit/bit.your-library.components.buttons

Then you can import the component in your app with:

import Button3 from '@bit/bit.your-library.components.buttons';

The good part is that you don't have to worry about configuring Webpack and all that jazz. Bit even supports the versioning of your components. This example shows a title-list react component so you can take a look if this meets your requirements or not

Vicente
  • 2,304
  • 11
  • 15
1

There is a configuration in webpack to create chunk files. To start with it will create the main bundle into multiple chunks and get it loaded as when required. if your project has well structured modules, it will not load any code which is not required.

Ireal
  • 355
  • 4
  • 16