At RateSetter we have a number of front-end projects that are worked on by different project teams. Each of these projects are designed by our internal designer and had a lot common with them (same inputs, buttons designs etc). Up until now, we had no shared style-sheet or components, so each of these components were created from scratch or copy pasted over each time. I saw a real need for a RateSetter component library. This component library would enable teams to pull down an NPM package, import components, provide some props and have their RateSetter styled component ready to use.

I strongly believed the component would help speed up front-end development by removing the need to write/copy paste components over to new projects and also great for me to learn how to create, managed and get people using a component library used in production throughout multiple engineering teams and projects - so I created RateSetter's component library!

Component Library Overview

There were a number of requirements I had for the component library. Being:

  • Must be React
  • Must house a number of components
  • Must use Sass
  • Must use TypeScript
  • Each component must be thoroughly tested (using Jest/Enzyme)
  • Must be able to bundle the components and be published to an internal NPM registry as a single library
  • Components should be able to be displayed and interacted with on an internal website

Just give me the component library skeleton

Sometimes it's just easier to clone a repository as opposed to following along with the article creating everything from scratch, I get it. For your convenience I've created a repository which has all the configuration needed for creating your own component library:

HarveyD/react-component-library
Contribute to HarveyD/react-component-library development by creating an account on GitHub.

Clone it then give it a star if it helps you out :). If you feel like it can be improved, please raise a pull request or a GitHub issue!

Component Library Research

Other Component Libraries

Before I created our own component library, I had a look around to see what other React component libraries were out there and whether we could use them. Turns out there's quite a few great libraries such as:

Our designer had created the designs for the RateSetter components with a specific style that none of these libraries could easily achieve without going into the internals of the components and altering them. So I decided it would be easier to create our own components.

Component Library Creation Tools

I then had a look around at a number of React project skeletons I could use to bootstrap the component library. But I eventually decided to just create the Component Library from scratch. Here are the libraries I considered:

All of these libraries had excellent documentation, support and a number of features that made it easy to get different flavours of React projects up and running. But there was always an aspect that wasn't able to be configured easily to meet my requirements (since they abstracted config away). For example, I wanted to use Typescript, but NWB didn't have great support for it. I also wanted to use Sass, but I found it tricky to get Create React Library transforming and bundling the .scss files.

Due to all the above not 100% matching the requirements I had for the component library - I decided to create all the config from scratch!

Custom Component Library Choices

Rollup

My decision for a JavaScript module bundler to use was between Webpack and Rollup. I decided to use Rollup for the component library after researching what both Webpack and Rollup are good and bad at. This article goes into depth about these differences. The take-home is that:

Use webpack for apps, and Rollup for libraries

Other articles you can read about these differences are here and here.

Storybook

I found out about Storybook in an interview I had with a web agency a few years ago. They explained they were using Storybook to help them create, display and experiment with their React components. After the interview, I did my own research and experimentation with Storybook and fell in love with the tool. Storybook felt like a perfect fit for the RateSetter component library.

Storybook provides a sand-boxed environment for your front-end project that helps you to develop your components. The sandbox environment encourages engineers to create components that aren't tied logic within your application (for example, heavily coupled with a Redux store). This results in components that are more generic and more re-usable.

Storybook also has the ability to be exported as a static webpage. This allows everyone in your organisation to see how components look/interact without having to clone and setup the repository locally - perfect for product managers and designers.

Creating the component library

Since we're creating our  project from scratch, we need to setup the structure. So after setting up NPM (npm init), giving our package the name react-component-library and also setting up GIT (git init).

Since we're creating a React component library, we need to install React:

npm i --save react react-dom
npm i --save-dev @types/react

The react and react-dom dependencies should be configured as Peer Dependencies. Having React as a peer dependency will mean that once our library is installed, React won't be installed as a dependency for our project. Instead, NPM will output a warning saying React is required if it hasn't been installed alongside our library.

Move react and react-dom from under dependencies to peerDependencies:

  "peerDependencies": {
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }

Now let's create the skeleton of our library by creating the following files and directories:

.gitignore
package.json
rollup.config.js
tsconfig.json
jest.config.js
setupTests.ts
src/
  test-component/
    test-component.tsx
    test-component.scss
    test-component.stories.js
    test-component.test.ts
  index.tsx

In test-component.tsx we will have a very simple component:

import React from "react";

import './test-component.scss';

interface IProps {
    theme: 'primary' | 'secondary';
}

const TestComponent: React.FC<IProps> = ({ theme }) => (
  <div className={`test-component test-component-${theme}`}>
    <h1 className="heading">I'm the test component</h1>
    <h2>Made with love by Harvey</h2>
  </div>
);

export default TestComponent;

And in test-component.scss we will have:

.test-component {
    background-color: white;
    border: 1px solid black;
    padding: 16px;
    width: 360px;
    text-align: center;
    
    .heading {
        font-size: 64px;
    }

    &.test-component-secondary {
        background-color: black;
        color: white;
    }
}

index.tsx will be used as the entry point for Rollup. Within index.tsx we will need to import and also export TestComponent, this is so when we bundle and export the library - all the components will be too. It will look like:

import TestComponent from "./test-component/test-component";

export { TestComponent };

We will leave test-component.stories.js and test-component.test.tsx empty for now.

Adding TypeScript

Run npm i -D typescript and in tsconfig.json add:

{
  "compilerOptions": {
    "outDir": "build",
    "module": "esnext",
    "target": "es5",
    "lib": ["es6", "dom", "es2016", "es2017"],
    "sourceMap": true,
    "allowJs": false,
    "jsx": "react",
    "declaration": true,
    "moduleResolution": "node",
  },
  "include": ["src","images.d.ts"],
  "exclude": ["node_modules", "build"],
  "esModuleInterop": true
}

Alternatively, you can run tsc --init to generate this file and provide your own config.

Adding Rollup

First, install Rollup in addition to some plugins required for our component library to be bundled correctly:

npm i -D rollup rollup-plugin-typescript2 rollup-plugin-sass rollup-plugin-commonjs rollup-plugin-node-resolve rollup-plugin-peer-deps-external

Create rollup.config.js and inside we'll put:

import typescript from "rollup-plugin-typescript2";
import sass from "rollup-plugin-sass";
import commonjs from "rollup-plugin-commonjs";
import external from "rollup-plugin-peer-deps-external";
import resolve from "rollup-plugin-node-resolve";

import packageJson from "./package.json";

export default {
  input: "src/index.tsx",
  output: [
    {
      file: packageJson.main,
      format: "cjs",
      sourcemap: true
    },
    {
      file: packageJson.module,
      format: "es",
      sourcemap: true
    }
  ],
  plugins: [
    external(),
    resolve({
      browser: true
    }),
    typescript(),
    commonjs({
      include: ["node_modules/**"],
      exclude: ["**/*.stories.js"],
      namedExports: {
        "node_modules/react/react.js": [
          "Children",
          "Component",
          "PropTypes",
          "createElement"
        ],
        "node_modules/react-dom/index.js": ["render"]
      }
    }),
    sass({
      insert: true
    })
  ]
};

Lets quickly run through the settings in this file:

input

points to src/index.tsx. This index file will act an entry point for all our components which Rollup will then know to bundle.

output

is an array with two objects, each specifying output config. We will be outputting two bundles in different formats: CommonJS (CJS) and ES Modules (ESM). This is because we want to support tools that use CommonJS (Webpack, Node.js) and tools that can use ES Modules (Webpack 2+, Rollup). Read more about CJS, ESM and other output formats in Rollup's documentation.

We will import the filename of our desired CommonJS and ES Modules index file from package.json. The main field in package.json points to our compiled CommonJS entry point and the module field points to our compiled ES Modules entry point. If a tool can support ESM, it'll use module otherwise it'll use main.

We will be outputting all our bundles to the build directory. Add the main and module fields in package.json in addition to the files field to instruct NPM what files to include when the library is installed in another project:

"main": "build/index.js",
"module": "build/index.es.js",
"files": ["build"],

plugins

is an array of 3rd party Rollup plugins. The plugins I've included are ones that are required to bundle the component library. A complete list of plugins can be found here. Let's go through all the plugins we're using:

  • external - prevents Rollup from bundling our peer dependencies (react and react-dom)
  • resolve - bundles third party dependencies we've installed in node_modules. We set browser: true as we are bundling for a web environment.
  • typescript - compiles our Typescript files
  • commonjs - compiles our JavaScript files into CommonJS files. The namedExports value is required until React supports ESM and is explained here.
  • sass - compiles our SASS files. insert: true will inline compiled CSS into the head of our application. We could also export the CSS file, distribute it with our library and then import it in the consuming project. But I find it's much easier to just inline.

Running Rollup

Now we need to add a package.json script entry that will run our Rollup bundling process:

"scripts": {
  "build" "rollup -c"
}

Now by running rollup -c you should see Rollup do it's thing and create a /build folder that will contain the compiled (CJS and ESM) component library, ready to be published to an NPM registry:

Note: you might want to create a .gitignore file and add node_modules and build to it.

Adding Storybook

The first step to add Storybook was to run the automatic setup command (documented here):

npx -p @storybook/cli sb init --type react

Since we are using TypeScript and Sass for our components, there's some additional config we have to add to get Storybook working nicely.

Navigate to the newly created .storybook directory and create a webpack.config.js file. In this file add:

const path = require('path');

module.exports = async ({ config, mode }) => {
  config.module.rules.push({
    test: /\.scss$/,
    use: ['style-loader', 'css-loader', 'sass-loader'],
    include: path.resolve(__dirname, '../'),
  });

  config.module.rules.push({
    test: /\.(ts|tsx)$/,
    loader: require.resolve('babel-loader'),
    options: {
      presets: [['react-app', { flow: false, typescript: true }]],
    },
  });
  config.resolve.extensions.push('.ts', '.tsx');

  return config;
};

Storybook uses Webpack and if we create this config file, Storybook will use it when running Webpack. We need to install some Webpack loaders to get the above config working:

npm i -D @babel/core babel-loader sass-loader

Since we are placing our stories within the component's folders and not within the storybook directory, we'll need to change the config. Within .storybook/config.js, update the file to be:

import { configure } from '@storybook/react';

// automatically import all files ending in *.stories.js
configure(
  [
    require.context('../src', true, /\.stories\.js$/),
  ],
  module
);

Also delete the stories directory which was generated by the Storybook CLI as we already created the story file alongside the component itself.

Now, we have to create stories for our TestComponent. Open to src/test-component/test-component.stories.js and place:

import React from "react";
import TestComponent from './test-component';

export default {
  title: "TestComponent"
};

export const Primary = () => <TestComponent theme="primary" />;

export const Secondary = () => <TestComponent theme="secondary" />;

This is a very basic story, showing the two variants of our component.

Storybook should have created a new entry in package.json scripts called, storybook, so run npm run storybook and Storybook will do it's magic and load up your components at localhost:6006.  Storybook should look like:

Adding Jest/Enzyme

Maintaining a high level of test coverage on components is extremely important for the library. We need to have confidence that when we make changes to our components that we won't be breaking how the component is being expected to behave in another project. For testing our React components, we will be using Jest and Enzyme.

Install Jest, Enzyme and Enzyme helpers (and associated types):

npm i --save-dev jest ts-jest enzyme enzyme-adapter-react-16 @types/jest @types/enzyme-adapter-react-16 @types/enzyme enzyme-to-json identity-obj-proxy

In jest.config.js add:

module.exports = {
  roots: ["./src"],
  setupFiles: ["./setupTests.ts"],
  moduleFileExtensions: ["ts", "tsx", "js"],
  testPathIgnorePatterns: ["node_modules/"],
  transform: {
    "^.+\\.tsx?$": "ts-jest"
  },
  testMatch: ["**/*.test.(ts|tsx)"],
  moduleNameMapper: {
    // Mocks out all these file formats when tests are run
    "\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
      "identity-obj-proxy",
    "\\.(css|less|scss|sass)$": "identity-obj-proxy"
  },
  snapshotSerializers: ["enzyme-to-json/serializer"]
};

  

And in setupTests.ts add:

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

And add two new scripts in package.json to run the tests:

"test": "jest --watch",
"test:once": "jest",

Then in test-component.test.tsx create two simple tests:

import React from "react";
import { shallow } from "enzyme";

import TestComponent from "./test-component";

describe("Test Component", () => {
  let props: any;

  beforeEach(() => {
    props = {
      theme: "primary"
    };
  });

  const renderWrapper = () => shallow(<TestComponent {...props} />);

  describe("Snapshots", () => {
    it("should match snapshots as primary themed", () => {
      expect(renderWrapper()).toMatchSnapshot();
    });

    it("should match snapshots as secondary themed", () => {
      props.theme = "secondary";
      expect(renderWrapper()).toMatchSnapshot();
    });
  });
});

After running npm run test you should see Jest run and output:

Indicating we have set it up correctly!

Using the Component Library

With our first component created, we probably want to be able to import and use our components in other React projects. Fortunately, we don't have to publish the component library to an NPM registry before installing our library and trialling out our components.

Let's say you had a React project on your local machine called harvey-test-app. In harvey-test-app run:

npm i --save ../react-component-library

This will install your local instance of the component library as a dependency to harvey-test-app!

Then in the harvey-test-app project we can import our TestComponent like:

import React from "react";
import { TestComponent } from "react-component-library";

const App = () => (
    <div className="app-container">
        <h1>Hello I'm consuming the component library</h1>
        <TestComponent theme="primary" />
    </div>
);

export default App;

Running harvey-test-app should now successfully render our TestApp component!

CRA app consuming our TestApp component

Adding More Components

The way we've structured our project allows for any number of components to be added. Simply copy the test-component folder, rename and then build out your new component!

Just remember to add the new component to index.tsx otherwise it won't be picked up and bundled by Rollup.

Further Steps

Setting up a Private NPM Registry, CI Pipeline and Consuming the Library

The next steps to get this component library was to setup a private NPM registry and pipeline which automatically publishes/maintains library versioning. I'll be covering this aspect in another blog post which you can read at: https://blog.harveydelaney.com/setting-up-a-private-npm-registry-publishing-ci-cd-pipeline/

Maintaining Code Quality in your Component Library

As this component library will likely be contributed by other engineers on your team, you'll probably want to maintain a high level of code quality by using static code analysis. Follow along with: https://blog.harveydelaney.com/maintaining-code-formatting-and-quality-automatically/ to help achieve this.