Whether you need reusable components internally at your job or you want to build the next Material UI, at some point you will have the need to build a component library. Fortunately tools like Storybook make it pretty easy to get setup, develop and review your React components in isolation. There is still quite some overhead in terms of configuration though which will add a lot of manual work to your todo list.
Having done this setup recently I wanted to spare you the hassle and show you a possible setup. Warning: this will be quite opinionated and I will not explain every decision or line of code. Take it more as a template that you can take and refine.
If you want to skip the step-by-step setup you can directly head to https://github.com/DennisKo/component-library-template and grab the finished code.
Main tools and libraries we will use:
From scratch
Init a git repository and a new NPM package. We will use Yarn throughout the setup, everything is also possible with npm of course.
mkdir my-component-library
dev cd my-component-library
git init
yarn init -y
Open package.json
and change the "name" field to something you like. I chose @dennisko/my-component-library
.
Create a .gitignore
:
node_modules
lib
.eslintcache
storybook-static
Add react
and react-dom
:
yarn add -D react react-dom
The -D is intended as we dont want to bundle React with our library we just need it in development and as a peer dependency. Add it to your package.json
accordingly:
"peerDependencies": {
"react": ">=17.0.1",
"react-dom": ">=17.0.1"
}
We will also install Typescript and add a tsconfig.json
:
yarn add -D typescript
tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"declaration": true,
"outDir": "./lib"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib"]
}
Now we can run npx sb init
which will install and add some default Storybook settings. It also creates some demo stories which we will not need and I suggest to delete the ./stories
folder. We will use a different structure:
.
└── src/
└── components/
└── Button/
├── Button.tsx
├── Button.stories.tsx
└── Button.test.tsx
I prefer to have everything related to a component in one place - the tests, stories etc.
To tell Storybook about our new structure we have to make a small change in .storybook/main.js
:
"stories": [
"../src/**/*.stories.mdx",
"../src/**/*.stories.@(js|jsx|ts|tsx)"
]
While we are there we also edit ./storybook/preview.js
to show the Storybook DocsPage page by default.
.storybook/preview.js
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
viewMode: 'docs',
};
Our first component
Now we can actually start coding and add our first component.
src/components/Button.tsx
import * as React from 'react';
export interface ButtonProps {
children: React.ReactNode;
primary?: boolean;
onClick?: () => void;
backgroundColor?: string;
color?: string;
}
export const Button = ({
children,
primary = false,
onClick,
backgroundColor = '#D1D5DB',
color = '#1F2937',
}: ButtonProps): JSX.Element => {
const buttonStyles = {
fontWeight: 700,
padding: '10px 20px',
border: 0,
cursor: 'pointer',
display: 'inline-block',
lineHeight: 1,
backgroundColor: primary ? '#2563EB' : backgroundColor,
color: primary ? '#F3F4F6' : color,
};
return (
<button type="button" onClick={onClick} style={buttonStyles}>
{children}
</button>
);
};
It is not a beauty, it is using hard coded colors and it is probably buggy already but it will suffice for our demo purposes.
Add two index.ts
files to import/export our Button component.
src/components/Button/index.ts
export { Button } from './Button';
src/index.ts
export { Button } from './components/Button';
Your project should look like that now:
Our first story
When we run yarn storybook
now it actually builds but shows a boring screen once we open http://localhost:6006/
.
That is because we have not added any stories for our Button component yet. A story lets us describe a state for a component and then interact with it in isolation.
Lets add some stories!
src/component/Button/Button.stories.tsx
import * as React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import { Button, ButtonProps } from './Button';
export default {
title: 'Button',
component: Button,
description: `A button.`,
argTypes: {
backgroundColor: { control: 'color' },
color: { control: 'color' },
primary: { control: 'boolean' },
},
} as Meta;
//👇 We create a “template” of how args map to rendering
const Template: Story<ButtonProps> = (args) => <Button {...args}>Click me</Button>;
//👇 Each story then reuses that template
export const Default = Template.bind({});
Default.args = {};
export const Primary = Template.bind({});
Primary.args = {
primary: true,
};
export const CustomBackground = Template.bind({});
CustomBackground.args = {
backgroundColor: '#A78BFA',
};
export const CustomFontColor = Template.bind({});
CustomFontColor.args = {
color: '#1E40AF',
};
export const OnClick = Template.bind({});
OnClick.args = {
// eslint-disable-next-line no-alert
onClick: () => alert('Clicked the button!'),
};
The structure and syntax here takes a bit to get used to but in general the default export in a *.stories file is used to add meta information like parameters (props in React land) and descriptions to our stories. Every named export like export const Primary
will create a story.
Run yarn storybook
again and we should see our Button with its stories in all its glory!
Play around with the UI and try to edit the Button stories, change some args (props!) and see what happens.
Tests
Although Storybook is great to manually test and review your components we still want to have automatic testing in place. Enter Jest and React Testing Library.
Install the dependencies we need for testing:
yarn add -D jest ts-jest @types/jest identity-obj-proxy @testing-library/react @testing-library/jest-dom
Create a jest.config.js
and jest-setup.ts
.
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
};
JSdom is the environment react-testing
needs and although not needed in this setup the moduleNameMapper makes Jest work with images and styles. identity-obj-proxy
is especially useful when you plan to use css modules.
jest-setup.ts
import '@testing-library/jest-dom';
__mocks__/fileMocks.js
module.exports = 'test-file-stub';
To run the tests we add two scripts to package.json
:
"test": "jest",
"test:watch": "jest --watch"
Now we are ready to write tests for our Button.
src/components/Button/Button.test.tsx
import * as React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
test('renders a default button with text', async () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
expect(screen.getByText('Click me')).toHaveStyle({
backgroundColor: '#D1D5DB',
color: '#1F2937',
});
});
test('renders a primary button', async () => {
render(<Button primary>Click me</Button>);
expect(screen.getByText('Click me')).toHaveStyle({
backgroundColor: '#2563EB',
color: '#F3F4F6',
});
});
test('renders a button with custom colors', async () => {
render(
<Button color="#1E40AF" backgroundColor="#A78BFA">
Click me
</Button>
);
expect(screen.getByText('Click me')).toHaveStyle({
backgroundColor: '#A78BFA',
color: '#1E40AF',
});
});
test('handles onClick', async () => {
const mockOnClick = jest.fn();
render(<Button onClick={mockOnClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(mockOnClick).toHaveBeenCalledTimes(1);
});
});
And run the tests once with yarn test
or in watch mode with yarn test:watch
.
Bundle it for production
Until now we have a nice development setup going. Storybook (with Webpack in the background) is doing all the bundling work.
To ship our code into the world we have to create a production ready bundle. An optimized, code-split and transpiled version of our code. We will use Rollup for that. It is also possible to do it with Webpack but I still go by the rule "Webpack for apps, Rollup for libraries". I also think that the Rollup config is quite a bit more readable than a webpack config, as you can see in a moment...
yarn add -D rollup rollup-plugin-typescript2 rollup-plugin-peer-deps-external rollup-plugin-cleaner @rollup/plugin-commonjs @rollup/plugin-node-resolve
rollup.config.js
import typescript from 'rollup-plugin-typescript2';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import cleaner from 'rollup-plugin-cleaner';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import packageJson from './package.json';
export default {
input: 'src/index.ts',
output: [
{
file: packageJson.main,
format: 'cjs',
sourcemap: true,
},
{
file: packageJson.module,
format: 'esm',
sourcemap: true,
},
],
plugins: [
cleaner({
targets: ['./lib'],
}),
peerDepsExternal(),
resolve(),
commonjs(),
typescript({
exclude: ['**/*.stories.tsx', '**/*.test.tsx'],
}),
],
};
We take the output paths from our package.json
, so we have to fill in the fields there and also add a "build" script:
"main": "lib/index.js",
"module": "lib/index.esm.js",
"scripts": {
...
"build": "rollup -c"
}
Publish to NPM
To manage versions and publishing to NPM we will use a library called changesets
. It will handle automatic patch/minor/major versions (SemVer) of our package and help us semi-automatically publishing to NPM.
yarn add --dev @changesets/cli
yarn changeset init
To make our library publicly available lets change the changeset config created at .changeset/config.json
and change access
to public
and probably the baseBranch
to main
. Keep access
at restricted
if you want to keep your library private.
Now everytime you make a change in your library, in a commit or PR, you type yarn changeset
and go through the cli and select what kind of change it was (patch/minor/major?) and add a description of your change. Based on that changesets
will decide how to bump the version in package.json
. So lets add a release
script and point the files
option package.json
to our lib
output directory.
package.json
"files": [
"lib"
],
"scripts": {
...
"release": "yarn build && changeset publish"
}
You would think we now run yarn release
to manually publish but changesets
takes it even one step further and provides a Github action to automate all of it.
Create .github/workflows/release.yml
:
name: Release
on:
push:
branches:
- main
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@master
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Setup Node.js 12.x
uses: actions/setup-node@master
with:
node-version: 12.x
- name: Install Dependencies
run: yarn
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@master
with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: yarn release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
For this to work you will need to create NPM access_token at https://www.npmjs.com/settings/NPM_USER_NAME/tokens. Choose the "Automation" option, copy the generated token and add it your github repository (under Settings -> Secrets) as NPM_TOKEN
.
When you commit and push these changes to Github the action workflow will run and release the initial version to NPM. It will also create a release & tag in github.
Now, lets assume we make a small change in our library, like changing the description of our button. We make our code changes and run yarn changeset
.
Pushing the changes to the main branch will trigger the release workflow again but this time it will not automatically publish to NPM, instead it will create a PR for us with the correctly adjusted library version. This PR will even get updated while more changes to main branch are pushed.
Once we are ready and satisfied with our changes we can merge that PR, which will trigger a publish to NPM again with the appropriate version.
Thats it. We build, tested and released a React component library!
Thanks for reading! I happily answer questions, and chat about possible bugs and improvements.
Also Follow me on Twitter: https://twitter.com/DennisKortsch
Top comments (8)
Hi Dennis,
Thanks for awesome article.
I am using module.scss file for styling component and getting error while testing.
error TS2307: Cannot find module './FormControl.module.scss' or its corresponding type declarations.
Please help how to resolve. module/scss working fine with storybook but test cases giving error.
Thanks
After run
yarn changeset
you said I just need to push to main branch and all the changes will be added in the pull request, but that's not happening, are you sure I need to push all the change that will be added in the generated PR to 'main' branch?or when you said main you are talking about 'changeset-release/main' ?
Firstly thanks for this tutorial. How can we add an external package like styled components or react table (dev peer or normal dependencies) ?
I'm also with this question, did you find something about it?
In most of the packages I have examined, the packages that will have an impact on development are installed as peerdependencies such as react, styled components, and the remaining packages (dnd, react-table, etc.) are installed as devdependencies or dependencies.
Awesome, will create my first library using this tutorial, can you tell me if the NPM package needs to be created first?
Storybook really is popular.
Does changesets have a gitlab option?