Setting up a React component library

Joey Imlay
EDF Data and Tech
Published in
10 min readAug 6, 2024

Creating a React component library is a powerful way to streamline the development process, enhance code reusability, and maintain a consistent look and feel across multiple projects. This guide highlights the best practices for building a robust and scalable React component library, leveraging Vite and its library mode, TypeScript, Conventional Commits, and Storybook.

The scope of this guide includes setting up the project and tooling it for the best possible developer experience, up to the point of creating components and the build process. It won’t prescribe one particular publishing process — there are simply too many solutions available to cover in any great detail here. It also assumes that the components you’re building have already been designed, and that a visual theme has been established. There will be helpful hints and caveats learned from experience about creating the contents of the library, but again, there are a great many tools and packages to choose from, each of which will come with its own considerations.

A package and a playground

As well as publishing an NPM package to be used by developers, this project also creates a live Storybook in which developers can view, test and play with the library’s components. It’s incredibly useful for developers to be able to see components in action, provide different values to each of the props, and to be able to request new features if their intended use case isn’t yet covered.

Vite in library mode

This library is bootstrapped by Vite, running in library mode, and written in Typescript to ensure type safety. All components in the library are transpiled to JavaScript, which means this library can be used in both JS and TS projects. Vite uses Rollup as its bundler, which means we get tree-shaking for free — although, with this project running in library mode, we do need to give it a helping hand to be fully tree-shakeable a little further into the process. Speaking of CSS, any styling system will work here.

To create the project, run yarn create vite. Name the project whatever you’d like, then choose React as your framework, and Typescript as your variant. Once Vite setup has completed, run yarn from your newly created project directory. It’s also a good idea to install Node types at this stage: yarn add -D @types/node.

You’ll see that Vite has created a src folder, as is typical for React projects. However, for our purposes, the src folder is entirely optional. If you want to create a working demo separate from the Storybook, feel free to add components to it as you go, but if not, feel free to ignore this folder entirely once library mode setup is complete. However, don’t delete it just yet.

Instead, three folders that haven’t been created yet will be important to us. The lib folder will be where we develop our components. dist will contain the bundled package, and storybook-static will contain our Storybook instance as a static web app.

We’ll create the lib folder ourselves, along with a main.ts file inside it, and then specify it as our library’s entry point in vite.config.js, thereby converting this project into library mode. (See also: https://vitejs.dev/guide/build#library-mode)

import { defineConfig } from ‘vite’;
import { resolve } from 'path';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'lib/main.ts'),
formats: ['es']
}
}
})

We’ll also need to make some tweaks to our TypeScript config. Inside tsconfig.json, update the “include” array to include lib:

"include": ["src", "lib"],

However, we don’t want src to be included in our package — only lib. To achieve this, we’ll need a separate TypeScript config for building, and some corresponding changes to our build script in package.json.

Create a new file named tsconfig-build.json and add the following properties to extend from tsconfig.json, but to only include lib:

{
"extends": "./tsconfig.json",
"include": ["lib"]
}

In the scripts section of package.json, update the build script to use our new build config:

"build": "tsc - p ./tsconfig-build.json && vite build",

We’ll need to copy vite-env.d.ts from src to lib, to ensure that TypeScript picks up on the type definitions that Vite requires when building. (If you’re not planning to use src in your project, go ahead and remove it now.)

Speaking of type definitions, we must ensure that the typedefs we create for our components are shipped with our package. Enter vite-plugin-dts, which does all the hard work for us. We just need to install (yarn add -D vite-plugin-dts) and update the plugins section of vite.config.js accordingly:

...
plugins: [
react(),
dts({ include: ['lib'] })
],

Library mode is now fully set up and ready to turn the contents of our lib folder into a package.

Conventional Commits

At this point, you’re probably very tempted to commit all the changes we’ve made so far. However, it’s important that we implement Conventional Commits as early as possible, and not just for a consistent commit history. The Conventional Commits system is crucial for publishing purposes, giving us a detailed history of the kinds of changes made in the project, allowing for automatic semantic versioning when we come to deploy the package.

We need a few tools to achieve this:

  • Commitizen uses Conventional Commits to bump your project’s version, create the changelog, and update files.
  • git-cz (short for git-commitizen) is a command line tool that works alongside Commitizen to create commit messages that conform to CC standards.
  • Commitlint checks if your commit messages meet the Conventional Commits format.
  • Husky allows you to run Git hooks, such as commit-msg, to enforce rules before committing — in this case, we’ll use it to trigger Commitlint.
yarn add -D commitizen
yarn commitizen init cz-conventional-changelog - save-dev - save-exact
yarn add @-D commitlint/config-conventional @commitlint/cli
yarn add -D git-cz
yarn add -D husky
yarn husky install

You should see a .husky directory in the root of your project. Add a commit-msg hook to prevent any commit messages that don’t follow the Conventional Commits system:

yarn commitlint - edit "$1"

In package.json, add two new scripts and a new config section:

"scripts": {

"commit": "git-cz",
"prepare": "husky install"
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
},

Finally, create a commitlint.config.js file in the root of your project, and add the following to it:

module.exports = { extends: ['@commitlint/config-conventional'] };

You’ll need to resist the urge to run git commit going forward — instead, use yarn commit to trigger git-cz in your CLI for perfect commits.

CSS injection

To ensure that the CSS we build to style our components is packaged properly, we need to make use of another Vite plugin, vite-plugin-lib-inject-css. This injects any .css files we create at the top of each chunk file using import statements, and, most usefully to our needs, supports multi-entry builds, which is what we’ll be implementing next.

After adding the plugin with yarn add -D vite-plugin-lib-inject-css, add it to the plugins section of vite.config.js:

...
plugins: [
react(),
libInjectCss(),
dts({ include: ['lib'] })
],

Tree-shaking

Earlier I mentioned that we get tree-shaking for free with Rollup, but that we’d need to help it along whilst we’re in library mode. Right now, all our transpiled JavaScript and CSS is sent to our users, no matter how much or how little of the library they’re actually using. In short, everyone gets everything, which adds a lot of unnecessary bloat. We only want our users to get what they need from the library, nothing more.

So, instead of having just one entry point — currently lib/main.ts — we turn every component into an entry point, splitting up all the JS and CSS so that each component can be utilised and bundled entirely independently of the others.

After installing globyarn add -D glob — we can make some changes to rollupOptions in vite.config.js, as well as adding a new output property to name and organise files within the package:

...
rollupOptions: {
external: ['react', 'react/jsx-runtime'],
input: Object.fromEntries(
glob
.sync('lib/**/*.{ts,tsx}', {
ignore: ['lib/**/*.d.ts', 'lib/**/*.stories.tsx'],
})
.map(file => [
relative('lib', file.slice(0, file.length - extname(file).length)),
fileURLToPath(new URL(file, import.meta.url)),
]),
),
output: {
assetFileNames: 'assets/[name][extname]',
entryFileNames: '[name].js',
},
},

(Note that I’ve added 'lib/**/*.stories.tsx' to the ignore array here — those are the Storybook files that we’ll create later on. We don’t want to export stories with the package; our Storybook demo is a separate concern within this project.)

In future, when we run yarn build, the dist folder will contain our components organised in the same structure as we’ve built them, along with an assets directory for our CSS.

With the file-splitting taken care of, package.json requires a little housekeeping. It needs to know where our package files, entry point and typedefs are:

...
"main": "dist/main.js",
"types": "dist/main.d.ts",
"files": [
"dist"
],

In order to prevent CSS files from being removed by the tree-shaking that we’ve just set up, we’ll need to add sideEffects as a new property here too:

...
"sideEffects": [
"**/*.css"
],

Further down, you’ll notice that react and react-dom are your only two dependencies at this stage. Move them into devDependencies — go ahead and delete the dependencies property — and also set them as peerDependencies, as they’re required for the package to work:

...
"peerDependencies": {
"react": "¹⁸.2.0",
"react-dom": "¹⁸.2.0"
},
"devDependencies": {
"react": "¹⁸.2.0",
"react-dom": "¹⁸.2.0",
...
}

Finally, add "prepublishOnly": "npm run build" to scripts. This ensures that changes you make to the library are built before the package is published.

With that, the library is now set up for publishing a package, and we can turn our minds to creating the contents of the package. Enter Storybook.

Storybook

Storybook is an open-source tool for developing UI components in isolation. It provides a powerful way to create, manage, and test UI components through stories. For this project, we’ll use Storybook to do all of the above, as well as to provide documentation for developers.

We won’t go into detail about constructing stories for your components — the assumption is that each component has its own corresponding .stories.tsx file — except to say that the autodocs feature does the work of providing code examples and descriptions of props for you.

Instead, after installing Storybook — yarn dlx storybook@latest init — we’ll build on the Storybook config to enable interaction testing. First, install the test and addon-interactions add-ons:

yarn add -D @storybook/test @storybook/addon-interactions

Update .storybook/main.ts to include the interactions add-on:

const config: StorybookConfig = {
...
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
},

In order to reuse a story as an interaction test case, the interactions add-on provides a play function, within which we can use testing utility functions from test. Below is a very basic use within a story for a spinner component:

export const Default: Story = {
args: {
...
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const spinner = canvas.getByTestId('spinner-loading');
expect(spinner).toBeInTheDocument();
}
};

Storybook also provides us a method of running all interaction tests in the project at once, which can come in useful for validating commits. Add the test runner: yarn add — dev @storybook/test-runner — then add a new script in package.json:

...
"scripts": {
...
"test-storybook": "test-storybook",
},

One more recommendation for Storybook is the a11y add-on — yarn add -D @storybook/addon-a11y, then add to addons in .storybook/main.ts — which runs Axe within your stories and provides alerts when accessibility violations are detected.

Finally, we need to add a script to package.json to allow for building a static version of Storybook in the storybook-static directory that can be deployed as a standalone web app:

...
"scripts": {
...
"build-storybook": "storybook build",
},

Building the library

Now that we’ve prepared the way for the package to be published and the Storybook to aid our development, you’re all set to start building components. How you go about building them is up to you — what follows is a few recommendations, caveats, and points to consider.

File structure: Structure your library in a way that’s effective and makes sense to you. Brad Frost’s Atomic Design methodology can be helpful in organising your components in a way that makes it clear how your components depend upon each other in their composition. It’s also useful to document each component’s dependencies in Storybook, so users can see how each component has been constructed.

Documentation: Speaking of documentation, you’re going to want to make yours as comprehensive and foolproof as possible. Don’t assume any knowledge on the part of your users; tell them everything they might need to know about using each component. Add as much detail to your Storybook stories as possible, and be sure to create a README. If you’re opening up your library to contributions, it’s also a good idea to create a CONTRIBUTING guide.

Unit testing: If you’re planning to do any unit testing — for example, of any util functions you need — don’t forget that Vite and Jest don’t play well together. Use Vitest instead.

Theming: If you’re implementing theming support, the good news is that Storybook can support theme switching with the use of decorators in .storybook/preview.tsx. However, there’s one major caveat: if you’re using a CSS-in-JS solution which comes with a useTheme custom hook, that you want to be made available to your users, you’ll have to export it from your library. An external instance of your CSS-in-JS library won’t be able to access the theme from your library.

Continuous Integration (CI): There are so many different solutions for CI that it would be outside of the scope of this guide to include them all here. However, you’ll need a workflow to publish your package, update the version number and create release notes (which is where Conventional Commits become so, so important). semantic-release is a great package that automates the process for you. You’ll also need a separate workflow to build and deploy Storybook to the web.

Many thanks to Andreas Riedmüller whose piece on Vite library mode formed the basis of this guide, and has been invaluable to me in my own work. Meanwhile, I’ve compiled all the reference materials I used in the creation of this guide here.

--

--

Joey Imlay
EDF Data and Tech

Software engineer at Accenture Next Gen Engineering. She/her. DFTBA.