Back to overview

Create a React Library and Publish to NPM

LibraryReact

Init Project and Create Components

npm init
npm i react typescript @types/react -D

File structure:

📦src
 ┣ 📂components
 ┃ ┗ 📂DrButton
 ┃ ┃ ┣ 📜DrButton.css
 ┃ ┃ ┗ 📜DrButton.tsx
 ┗ 📜index.ts

Create a button component

src/components/DrButton/DrButton.tsx:

import React from 'react';

export interface ButtonProps {
  label: string;
}

const DrButton = (props: ButtonProps) => {
  return <button>{props.label}</button>;
};

export default DrButton;

finally export all components:

src/index.ts:

export {default as DrButton} from './components/DrButton/DrButton';

Adding typescript

npx tsc --init

That will create a tsconfig.json file for us in the root of our project that contains all the default configuration options for Typescript.

https://www.typescriptlang.org/docs/handbook/tsconfig-json.html

You may notice depending on your IDE that immediately after initializing you begin to get errors in your project. There are two reasons for that: the first is that Typescript isn't configuration to understand React by default, and the second is that we haven't defined our method for handling modules yet: so it may not understand how to manage all of our exports.

To fix this we are going to add the following values to tsconfig.json:

{
  "compilerOptions": {
    // Default
    "target": "es5",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,

    // Added
    "jsx": "react",
    "module": "ESNext",
    "declaration": true,
    "declarationDir": "types",
    "sourceMap": true,
    "outDir": "dist",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "emitDeclarationOnly": true
  }
}
  • "jsx": "react" -- Transform JSX into React code
  • "module": "ESNext" -- Generate modern JS modules for our library
  • "declaration": true -- Output a .d.ts file for our library types
  • "declarationDir": "types" -- Where to place the .d.ts files
  • "sourceMap": true -- Mapping JS code back to its TS file origins for debugging
  • "outDir": "dist" -- Directory where the project will be generated
  • "moduleResolution": "node" -- Follow node.js rules for finding modules
  • "allowSyntheticDefaultImports": true -- Assumes default exports if none are created manually
  • "emitDeclarationOnly": true -- Don't generate JS (rollup will do that) only export type declarations

Add Rollup and dependencies

Rollup is great to build libraries. We need other rollup plugins for additional features like:

  • Preventing bundling of peerDependencies

  • Minifying the final bundle

  • @rollup/plugin-node-resolve - Uses the node resolution algorithm for third-party dependencies in node_modules

  • @rollup/plugin-typescript - Teaches rollup how to process Typescript files

  • @rollup/plugin-commonjs - Converts commonjs modules to ES6 modules

  • rollup-plugin-dts - rollup your .d.ts files

2 Optimizing plugins:

  • rollup-plugin-peer-deps-external - With rollup's peer dependencies plugin we can tell the projects that are using our libraries which dependencies are required (like React) but won't actually bundle a copy of React with the library itself. If the consumer already has React in their project it will use that, otherwise it will get installed when they run npm install.
  • rollup-plugin-terser - minify our bundle and reduce the overall file size
npm i -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-peer-deps-external rollup-plugin-terser rollup-plugin-dts
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';
import {terser} from 'rollup-plugin-terser';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';

const packageJson = require('./package.json');

export default [
  // this object defines how the actual Javascript code of our library is generated.
  {
    input: 'src/index.ts', // The entrypoint for our library (input) which exports all of our components.
    output: [
      {
        file: packageJson.main,
        format: 'cjs',
        sourcemap: true,
      },
      {
        file: packageJson.module,
        format: 'esm',
        sourcemap: true,
      },
    ],
    plugins: [peerDepsExternal(), resolve(), commonjs(), typescript({tsconfig: './tsconfig.json'}), terser()],
    external: [
      'react',
      'react-dom',
      // 'styled-components'
    ],
  },
  // this object defines how our libraries types are distributed and uses the dts plugin to do so.
  {
    input: 'dist/esm/types/index.d.ts',
    output: [{file: 'dist/index.d.ts', format: 'esm'}],
    plugins: [dts()],
  },
];

Next, update package.json

{
  "name": "gloryui-react",
  "version": "1.0.0",
  "description": "a react components library",
+  "main": "dist/cjs/index.js",
+  "module": "dist/esm/index.js",
+  "files": [
+    "dist"
+  ],
+  "types": "dist/index.d.ts",
+  "scripts": {
+    "rollup": "rollup -c"
+  },
  "author": "Guanghui Wang",
  "license": "ISC",
  "devDependencies": {
    "@rollup/plugin-commonjs": "^21.0.1",
    "@rollup/plugin-node-resolve": "^13.1.3",
    "@rollup/plugin-typescript": "^8.3.0",
    "@types/react": "^17.0.39",
-    "react": "^17.0.2",
    "rollup": "^2.67.3",
    "rollup-plugin-dts": "^4.1.0",
    "rollup-plugin-peer-deps-external": "^2.2.4",
    "rollup-plugin-terser": "^7.0.2",
    "typescript": "^4.5.5"
  },
+  "peerDependencies": {
+    "react": "^17.0.2"
+  },
}

Thanks to rollup-plugin-peer-deps-external, we can move react from devDependencies to peerDependencies.

The most important changes are as follows:

  • "main" -- We have defined the output path for commonjs modules
  • "module" -- We have defined the output path for es6 modules
  • "files" -- We have defined the output directory for our entire library
  • "types" -- We have defined the location for our library's types
  • "scripts" -- rollup -c means use the rollup configuration file

File structure:

├── src
│   ├── components
|   │   ├── DrButton
|   |   │   └── DrButton.tsx
│   └── index.ts
├── package.json
├── package-lock.json
├── tsconfig.json
└── rollup.config.js

Run npm run rollup and see the dist folder:

📦dist
 ┣ 📂cjs
 ┃ ┣ 📂types
 ┃ ┃ ┣ 📂components
 ┃ ┃ ┃ ┗ 📂DrButton
 ┃ ┃ ┃ ┃ ┗ 📜DrButton.d.ts
 ┃ ┃ ┗ 📜index.d.ts
 ┃ ┣ 📜index.js
 ┃ ┗ 📜index.js.map
 ┣ 📂esm
 ┃ ┣ 📂types
 ┃ ┃ ┣ 📂components
 ┃ ┃ ┃ ┗ 📂DrButton
 ┃ ┃ ┃ ┃ ┗ 📜DrButton.d.ts
 ┃ ┃ ┗ 📜index.d.ts
 ┃ ┣ 📜index.js
 ┃ ┗ 📜index.js.map
 ┗ 📜index.d.ts

Publish NPM package

Now follow the instructions on Github shown in your new repository for committing your code.

We need to update package.json with that information:

{
  "name": "@YOUR_GITHUB_USERNAME/YOUR_REPOSITORY_NAME",
  "version": "0.0.1",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com/YOUR_GITHUB_USERNAME"
  }
}

You will be updating the field name value and adding a new field called publishConfig. Note the values above in caps are meant to be replaced with your own values. For example my name field value would be @wghlory/gloryui-react. Notice the packageConfig also has your Github account name in it as well, but that value does not lead with the @ symbol.

Now that we have configured out project, we need to configure our local install of NPM itself to be authorized to publish to your Github account. To do this we use a .npmrc file.

This file is NOT PART OF OUR PROJECT. This is a global file in a central location. For Mac/Linux users it goes in your home directory ~/.npmrc.

registry=https://registry.npmjs.org/
@YOUR_GITHUB_USERNAME:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=YOUR_AUTH_TOKEN

Go to your Github profile: Settings -> Developer Settings -> Personal access tokens. Or just click this link Click Generate new token. Give it a name that suits the project you are building. Give it an expiry date (Github recommends you don't create tokens with an infinite lifespan for security reasons, but that's up to you).

The most important thing is to click the write:packages access value. This will give your token permission to read & write packages to your Github account.

Now run npm publish!

If you get prompted for login credentials, the username is your Github username and your password is the access token you generated

You can see your package in github

You can view it on your Github account by going to your main account dashboard and clicking "packages" along the top to the right of "repositories".

Using Your Library

Now that your library is live, you'll want to use it!

Note that the instructions for using your library are slightly different if you published to a private repository. Everyone (aside from your own machine) who tries to import it is going to get a 404 Not Found error if they are not authorized.

Those users also need to add a ~/.npmrc file with the same information. To be more secure however you can provide those users with an access token that has only read privileges, not write.

Further Enhancement

If you choose to continue, we will look at how to expand our component library to include a number of extremely useful features such as:

  • CSS: For exporting components with style
  • Storybook: For testing our components within the library itself as we design them
  • React Testing Library & Jest: For testing our components

Adding CSS

Before we do any additional configuration, we'll begin by creating a CSS file that will apply some styles to our Button. Inside of the Button directory where our component lives, we'll create a file called: Button.css:

src/components/DrButton/DrButton.css:

button {
  font-size: 60px;
}

Notice that you will have build error due to the css import in DrButton.tsx:

+ import './DrButton.css';

const DrButton = (props: ButtonProps) => {
  return <button>{props.label}</button>;
};

Now we need to tell rollup how to process that syntax. To do that we use a plugin called rollup-plugin-postcss. Run the following command:

npm install rollup-plugin-postcss --save-dev

Now the rollup.config.js is like:

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import dts from 'rollup-plugin-dts';
import {terser} from 'rollup-plugin-terser';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';

+ import postcss from 'rollup-plugin-postcss';

const packageJson = require('./package.json');

export default [
  // this object defines how the actual Javascript code of our library is generated.
  {
    input: 'src/index.ts', // The entrypoint for our library (input) which exports all of our components.
    output: [
      {
        file: packageJson.main,
        format: 'cjs',
        sourcemap: true,
      },
      {
        file: packageJson.module,
        format: 'esm',
        sourcemap: true,
      },
    ],
    plugins: [
      peerDepsExternal(),
      resolve(),
      commonjs(),
      typescript({tsconfig: './tsconfig.json'}),
      terser(),
+      postcss(),
    ],
    external: [
      'react',
      'react-dom',
      // 'styled-components'
    ],
  },
  // this object defines how our libraries types are distributed and uses the dts plugin to do so.
  {
    input: 'dist/esm/types/index.d.ts',
    output: [{file: 'dist/index.d.ts', format: 'esm'}],
    plugins: [dts()],
+    external: [/\.css$/],
  },
];

In the dts config we need to specify that .css modules are external and should not be processed as part of our type definitions (otherwise we will get an error).

Finally we need to update the version number in our package.json file. Remember we are publishing a package so when we make changes, we need to ensure we don't impact users of previous versions of our library. Every time we publish we should increment the version number:

package.json:

{
+  "version": "0.0.2",
-  "version": "0.0.1",
  ...
}

Now run these commands:

npm run rollup
npm publish

In the testing app, upgrade the library and verify.

Adding Tests

npm install @testing-library/react @testing-library/jest-dom @testing-library/user-event  jest @types/jest --save-dev

src/components/DrButton/DrButton.test.tsx:

import React from 'react';
import '@testing-library/jest-dom';
import {render, screen} from '@testing-library/react';

import DrButton from './DrButton';

describe('DrButton', () => {
  test('renders the DrButton component', () => {
    render(<DrButton label="Hello world!" />);
    expect(screen.getByText(/hello world/i)).toBeInTheDocument();
  });
});

Add jest.config.js or npx jest --init:

module.exports = {
  testEnvironment: 'jsdom',
};

package.json script:

{
  "scripts": {
    "rollup": "rollup -c",
+    "test": "jest"
  },
}

Now we can run our tests with: npm test.

Unfortunately we are going to get an error! The error is when our JSX code is encountered. If you recall we used Typescript to handle JSX with our rollup config, and a Typescript plugin for rollup to teach it how to do that. We have no such setup in place for Jest unfortunately.

The error: Add @babel/preset-react (https://git.io/JfeDR) to the 'presets' section of your Babel config to enable transformation.

Let's install all babel plugins:

npm install @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript babel-jest --save-dev

One more thing to config: You'll get an error saying the import of the .css file isn't understood. That makes sense because, again, we configured a postcss plugin for rollup to handle that, but we did no such thing for Jest.

Let's add npm install identity-obj-proxy --save-dev.

We need to update our Jest config tp include the moduleNameMapper property. We've also added less and scss in there for good measure in case you want to expand your project later to use those:

module.exports = {
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '.(css|less|scss)$': 'identity-obj-proxy',
  },
};

Adding Storybook

npx sb init --builder webpack5

Note as of this writing Storybook still defaults to using webpack 4 which is why we have added the extra builder flag

Create src/components/DrButton/DrButton.stories.tsx:

import React from 'react';
import {ComponentStory, ComponentMeta} from '@storybook/react';
import DrButton from './DrButton';

// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
  title: 'DrComponentLibrary/DrButton',
  component: DrButton,
} as ComponentMeta<typeof DrButton>;

// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof DrButton> = (args) => <DrButton {...args} />;

export const HelloWorld = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
HelloWorld.args = {
  label: 'Hello world!',
};

export const ClickMe = Template.bind({});
ClickMe.args = {
  label: 'Click me!',
};

The default export defines where the button will appear in the Storybook. I've chosen DrComponentLibrary as a simple name to group our custom components together separately from the examples.

The Template determines which component is actually being rendered, and which default args/props to apply to it.

The Template.bind objects are instances or example states of the component. So in a real project you might have something like "LargeButton" and "SmallButton". Since our button is always big I've just used an example of testing the button with two different labels.

Now we can run npm run storybook.

I got an error: Error: Cannot find module 'react/package.json'. This link helps to solve the issue. They said "Check if you have added react as part of peer dependencies. It has to be there in dependencies also". But that will put react as dependencies which is not what we want.

A tricky solution is this:

If you simply run a fresh npm install command it will install all the peerDependencies of the libraries you are using. Before running this you may need to delete your package-lock.json and node_modules directory. They will be regenerated automatically after your fresh install.

It can be tricky to troubleshoot issues related to both overlapping and missing dependencies between libraries.

Now we can re-run npm run storybook.

Adding SCSS

Thanks to rollup-plugin-postcss you should already be able to simply rename your .css file to .scss and then import DrButton.scss and be on your way.

Change rollup.config.js external field:

-    external: [/\.css$/],
+    external: [/\.scss$/],
npm install @storybook/preset-scss css-loader sass sass-loader style-loader --save-dev

Now storybook will have an error: You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See webpack loaders.

All need to do is to add @storybook/preset-scss to your main Storybook config:

.storybook/main.js:

module.exports = {
  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
+    '@storybook/preset-scss',
  ],
  framework: '@storybook/react',
  core: {
    builder: 'webpack5',
  },
};

One last reminder that it's common to encounter dependency errors with Storybook. Before you begin installing the missing dependencies it asks for, always try deleting package-lock.json and node_modules first and then running npm install again. This will often fix your issue without requiring you to add unnecessary dependencies to your own project.

Reference

This git repo