Building a Component Library with React, Typescript, and Storybook: A Comprehensive Guide

Ashish Kachhadiya
Simform Engineering
15 min readSep 22, 2023

A thorough tutorial on creating your own React component libraries and sharing them across projects via GitHub and the npm repository.

React is one of the most famous libraries for making scalable web applications. The React ecosystem has many component libraries like Ant Design, Material UI, and Chakra UI, which provide reusable UI components.

With the flexibility of React, you can also build custom component libraries suited to their specific needs. In this article, we’ll learn about how to make your own component library with React, Typescript, and Storybook, along with a few other useful tools.

Project Setup with ESLint and Prettier

To initialize the project with git along with React and Typescript, run the following commands:

git init
npm init -y
npm install -D react @types/react typescript

Here, we need to move react to peerDependencies because it’s typically used as a peer dependency in library packages. This allows consumers to use one version of React without conflicts. To do this, add the following lines to your package.json and remove React from devDependencies:

"peerDependencies": {
"react": "^18.2.0"
},

Prettier

Prettier is an opinionated code formatter. It enforces a consistent style by parsing your code and re-printing it with its own rules.

To install Prettier, run the following command:

npm install -D prettier

Create .prettierrc at the root of the project and set rules as follows:

{
"printWidth": 80,
"tabWidth": 2
}

To format the project, add the following script in package.json :

{
...
"scripts": {
"format": "prettier --write --parser typescript '**/*.{ts,tsx}'"
},
...
}

ESLint

ESLint is a static code analysis tool that checks your JavaScript code for common problems, such as syntax errors, formatting issues, code style violations, and potential bugs.

To install the ESLint with its plugin, run the following command:

npm install -D eslint @typescript-eslint/parser eslint-config-prettier eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-storybook @typescript-eslint/eslint-plugin

Now, create a config file for ESLint named .eslintrc at the root of the project and paste the following configuration:

{
// Specify the environments where the code will run
"env": {
"jest": true, // Enable Jest for testing
"browser": true // Enable browser environment
},

// Stop ESLint from searching for configuration in parent folders
"root": true,

// Specify the parser for TypeScript (using @typescript-eslint/parser)
"parser": "@typescript-eslint/parser", // Leverages TS ESTree to lint TypeScript

// Add additional rules and configuration options
"plugins": ["@typescript-eslint"],

// Extend various ESLint configurations and plugins
"extends": [
"eslint:recommended", // ESLint recommended rules
"plugin:react/recommended", // React recommended rules
"plugin:@typescript-eslint/recommended", // TypeScript recommended rules
"plugin:@typescript-eslint/eslint-recommended", // ESLint overrides for TypeScript
"prettier", // Prettier rules
"plugin:prettier/recommended", // Prettier plugin integration
"plugin:react-hooks/recommended", // Recommended rules for React hooks
"plugin:storybook/recommended" // Recommended rules for Storybook
],
"rules": {
"react/react-in-jsx-scope": "off",
}
}

Create a .gitignore file in the root directory and add directories that we don’t have to include in the repository.

node_modules
dist

#storybook build directory
storybook-static

For linting the project, add the following script in package.json :

{
...
"scripts": {
"lint": "eslint . --ext .ts,.tsx --ignore-path .gitignore --fix"
},
...
}

Typescript and Vite Configuration

Vite, a modern front-end tool, has become popular in recent years. While it uses Rollup for production builds, it combines the strengths of both tools to offer a great development experience and efficient production builds.

Create a file named tsconfig.json and paste the following configuration:

{
"compilerOptions": {
"target": "ES5", // Specifies the JavaScript version to target when transpiling code.
"useDefineForClassFields": true, // Enables the use of 'define' for class fields.
"lib": ["ES2020", "DOM", "DOM.Iterable"], // Specifies the libraries available for the code.
"module": "ESNext", // Defines the module system to use for code generation.
"skipLibCheck": true, // Skips type checking of declaration files.

/* Bundler mode */
"moduleResolution": "bundler", // Specifies how modules are resolved when bundling.
"allowImportingTsExtensions": true, // Allows importing TypeScript files with extensions.
"resolveJsonModule": true, // Enables importing JSON modules.
"isolatedModules": true, // Ensures each file is treated as a separate module.
"noEmit": true, // Prevents TypeScript from emitting output files.
"jsx": "react-jsx", // Configures JSX support for React.

/* Linting */
"strict": true, // Enables strict type checking.
"noUnusedLocals": true, // Flags unused local variables.
"noUnusedParameters": true, // Flags unused function parameters.
"noFallthroughCasesInSwitch": true, // Requires handling all cases in a switch statement.
"declaration": true, // Generates declaration files for TypeScript.
},
"include": ["src"], // Specifies the directory to include when searching for TypeScript files.
"exclude": [
"src/**/__docs__","src/**/__test__"
]
}

To install Vite with one plugin that generates a declaration file, run the following command:

npm install -D vite vite-plugin-dts

Create a file named vite.config.ts in the root directory with the following configuration:

import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import { peerDependencies } from "./package.json";

export default defineConfig({
build: {
lib: {
entry: "./src/index.ts", // Specifies the entry point for building the library.
name: "vite-react-ts-button", // Sets the name of the generated library.
fileName: (format) => `index.${format}.js`, // Generates the output file name based on the format.
formats: ["cjs", "es"], // Specifies the output formats (CommonJS and ES modules).
},
rollupOptions: {
external: [...Object.keys(peerDependencies)], // Defines external dependencies for Rollup bundling.
},
sourcemap: true, // Generates source maps for debugging.
emptyOutDir: true, // Clears the output directory before building.
},
plugins: [dts()], // Uses the 'vite-plugin-dts' plugin for generating TypeScript declaration files (d.ts).
});

Add this configuration to package.json as it defines the entry points and types definitions with the build script.

{
...
"type": "module",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"files": [
"/dist"
],
"scripts":{
...
"build": "tsc && vite build",
}
}

Here, "main" and "module" are used to specify the entry points for different module systems in JavaScript.

  1. The "main" field is used to specify the entry point for CommonJS modules. It typically points to a file with the extension .cjs.js or .js. When a package is required using require() in Node.js or bundled with tools like Webpack or Vite, the "main" entry point is used.
  2. The "module" field is used to specify the entry point for ES modules. It typically points to a file with the extension .es.js or .mjs. When a package is imported using import in modern JavaScript environments that support ES modules, the "module" entry point is used.

By specifying both "main" and "module" fields in the package.json, we can provide compatibility for both CommonJS and ES module systems.

Creating Components

Instead of using simple CSS, we are going to use styled components for styling to benefit from features like component-based styling, dynamic styling, and css-in-js.

To add styled-components, run the following command.

npm install -D styled-components

Create a src folder in the root directory and then, create a folder named button for our button components. Add Button.tsx and index.ts in this folder and paste the following code:

// components/button/button.tsx
import React, { MouseEventHandler } from "react";
import styled from "styled-components";

export type ButtonProps = {
text?: string;
primary?: boolean;
disabled?: boolean;
size?: "small" | "medium" | "large";
onClick?: MouseEventHandler<HTMLButtonElement>;
};

const StyledButton = styled.button<ButtonProps>`
border: 0;
line-height: 1;
font-size: 15px;
cursor: pointer;
font-weight: 700;
font-weight: bold;
border-radius: 10px;
display: inline-block;
color: ${(props) => (props.primary ? "#fff" : "#000")};
background-color: ${(props) => (props.primary ? "#FF5655" : "#f4c4c4")};
padding: ${(props) =>
props.size === "small"
? "7px 25px 8px"
: props.size === "medium"
? "9px 30px 11px"
: "14px 30px 16px"};
`;

const Button: React.FC<ButtonProps> = ({
size,
primary,
disabled,
text,
onClick,
...props
}) => {
return (
<StyledButton
type="button"
onClick={onClick}
primary={primary}
disabled={disabled}
size={size}
{...props}
>
{text}
</StyledButton>
);
};

export default Button;
// components/button/index.ts
export { default as Button } from './Button';

Add an index.ts file to the components folder, as this file will allow you to export all the components from the components folder.

// components/index.ts
export * from './button'; // Add more exports for other components as needed

Add an index.ts file to the src folder as it serves as the entry point for your entire library. From here, you can export components along with their types and utilities.

// src/index.ts
export * from './components'; // This will export all components from the 'components' folder

After adding a component, run the following command. It generates a ‘dist’ folder.

npm run build

In the dist folder, you will find the output code for your library.

Testing with Vitest and React-Testing-Library

Vitest is the unit testing framework built on top of Vite and is an excellent unit test framework with many modern features.

To install Vitest, run the following command:

npm install -D vitest @testing-library/react jsdom @testing-library/jest-dom

Now, add the following script to thepackage.json file.

"scripts": {
"test": "vitest run",
"test-watch": "vitest",
"test:ui": "vitest --ui"
}

Add the following line at the top of the vite.config.ts file:

/// <reference types="vitest" />

Create a file namedsetupTests.ts in the root directory and add the following code to that file:

import { expect } from "vitest";
import * as matchers from "@testing-library/jest-dom/matchers";
import { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers";
declare module "vitest" {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface Assertion<T = any>
extends jest.Matchers<void, T>,
TestingLibraryMatchers<T, void> {}
}
expect.extend(matchers);

Now, add the following configuration to vite.config.ts file under defineConfig:

  test: {
globals: true,
environment: "jsdom",
setupFiles: "./setupTests.ts",
},

Create __test__ directory in the button folder and add a file named Button.test.tsx to test the button component with the following code:

//Button/__test__/Button.test.tsx
import React from "react";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import Button from "../Button";

describe("Button component", () => {
it("Button should render correctly", () => {
render(<Button />);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
});
});

Adding Storybook and Husky

Storybook

Storybook is an open-source development environment for designing, testing, and showcasing UI components in isolation.

Run the following command to initialize a new storybook project:

npx storybook@latest init

As we already have Vite, it will be detected as a runner in Storybook, and it will also add the .storybook folder along with the required script in the package.json file.

It will also generate a stories folder in the src folder, but we are going to delete it.

Each component has its __docs__ own directory, and to that, we will add our stories. To do that, we have to update the stories field in the .stroybook/main.ts file.

stories: ["../src/**/__docs__/*.stories.tsx", "../src/**/__docs__/*.mdx"],

Create three files in the src/button/__docs__ directory:

  • Button.stories.tsx
  • Example.tsx
  • Button.mdx

Add the following content to Button.mdx. :

import { Canvas, Meta } from "@storybook/blocks";
import Example from "./Example.tsx";
import * as Button from "./Button.stories.tsx";

<Meta of={Button} title="Button" />

# Button

Button component with different props.

#### Example

<Canvas of={Button.Primary} />

## Usage

```ts
import {Button} from "sld-ui";

const Example = () => {
return (
<Button
size={"small"}
text={"Button"}
onClick={()=> console.log("Clicked")}
primary
/>
);
};

export default Example;
```

#### Arguments

- **text** _`() => void`_ - A string that represents the text content of the button.
- **primary** - A boolean indicating whether the button should have a primary styling or not. Typically, a primary button stands out as the main action in a user interface.
- **disabled** - A boolean indicating whether the button should be disabled or not. When disabled, the button cannot be clicked or interacted with.
- **size** - A string with one of three possible values: "small," "medium," or "large." It defines the size or dimensions of the button.
- **onClick** - A function that is called when the button is clicked. It receives a MouseEventHandler for handling the click event on the button element.

In the Example.tsx file, insert the following code:

import React, { FC } from "react";
import Button, { ButtonProps } from "../Button";

const Example: FC<ButtonProps> = ({
disabled = false,
onClick = () => {},
primary = true,
size = "small",
text = "Button",
}) => {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
>
<Button
size={size}
text={text}
disabled={disabled}
onClick={onClick}
primary={primary}
/>
</div>
);
};

export default Example;

In the Button.stories.tsx file, insert the following code:

import type { Meta, StoryObj } from "@storybook/react";
import Example from "./Example";

const meta: Meta<typeof Example> = {
title: "Button",
component: Example,
};

export default meta;
type Story = StoryObj<typeof Example>;

export const Primary: Story = {
args: {
text: "Button",
primary: true,
disabled: false,
size: "small",
onClick: () => console.log("Button"),
},
};
export const Secondary: Story = {
args: {
text: "Button",
primary: false,
disabled: false,
size: "small",
onClick: () => console.log("Button"),
},
};

After making these changes, run the following command to start Storybook:

npm run storybook

You should be able to see all the Button UI variations in Storybook.

storybook with button component

Husky

Husky is primarily designed to enforce pre-commit hooks in your Git repository, ensuring that certain tasks like running tests, formatting code, and linting are performed before any commits are allowed.

To configure Husky with pre-commit hooks, run the following commands:

npm install -D husky lint-staged
npx husky install

Now, inside the .husky folder, create a file named pre-commit hook and add the following to it:

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged

Add the following configuration to thepackage.json file:

"lint-staged": {
"*.{ts,tsx}": [
"npm run format",
"npm run lint",
"npm run test"
]
}

Now, when we commit our changes using git commit, Husky will automatically run lint-staged, which, in turn, will run our specified formatting and linting scripts on the staged files.

Publishing the Library on NPM and GitHub Registry

Publishing on GitHub

To your project’s package.json, add a publishConfig section to specify the GitHub package registry as the registry. Replace {USER_NAME} and {repo-name} with your actual GitHub username and repository name. Here's an example:

"publishConfig": {
"registry": "https://npm.pkg.github.com/@{USER_NAME}"
},
"name": "@{user_name}/{repo-name}",

Modifying the permissions for the GITHUB_TOKEN: Go to the GitHub repository, click on settings, then navigate to Actions. In the General section, ensure that both Read and Write permissions are accepted, then click Save.

Actions permission

In your project’s root directory, create a .github/workflows folder if it doesn't already exist.

Inside that folder, create a file named publish-package.yml with the following content. here add your USER_NAME in the scope.

Here, GITHUB_TOKEN is automatically provided by GitHub Actions and is used for authentication when publishing the package.

name: publish on github

on:
push:
branches:
- master

jobs:
publish-gpr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 18
registry-url: https://npm.pkg.github.com/
scope: "@{USER_NAME}"
- run: npm install
- run: npm run test
- run: npm run build
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}

So, once you push to the master branch, this workflow will run automatically, and the package will be published.

You can find your library in the Packages section of your GitHub repository.

You can refer to my repository for assistance if you encounter any errors.

Publishing on NPM

First, create an account on npmjs.com. Then, navigate to your profile settings and click on Access Tokens.

Now, click on Generate Token and select Classic Token.

Now, provide a name for the token and select the type as Automation, and it will generate the token.

Next, go to your GitHub repository’s settings, and within the Secrets and variables section, navigate to Actions. Click on New repository secret Provide a name for the secret (this name will be used to access the token in the workflow), and then click Add secret .

We have to add a prepare script to your package.json to specify a command that runs when the package is prepared for publishing.

{
...
"scripts": {
"prepare": "npm run build"
},
...
}

In your project’s root directory, create a .github/workflows folder if it doesn't already exist. Inside that folder, create a file named npm-publish.yml with the following content.

name: publish npm

on:
push:
branches:
- main

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2

- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: 18
registry-url: https://registry.npmjs.org/

- name: Install dependencies
run: npm install

- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}}

So, once you push to the main branch, this workflow will run automatically, and the package will be published.

Now, if you check the Packages section of your account, you will find your package.

You can refer to my repository for assistance if you encounter any errors.

Using the Library Locally in Another Project for Testing

To test the library locally, create a directory named example and then run the following command to initialize a React app in that directory:

npm create vite@latest . -- --template react-ts
npm install

Now, go to the root directory and build your npm package with the following command:

npm run build

To ensure you’re using the same version of React as your example app, run the following command from the root directory:

npm link "./example/node_modules/react"

Return to your example app’s directory and link your npm package to the example app using the package name you specified in its package.json. Replace {package-name} with your package's name.

npm link {package-name}

You can check whether the package is linked or not using the following command:

npm ls --location=global --depth=0 --link=true

Now, import the UI component from the library and test easily locally.

//App.tsx
import { Button } from "sld-ui-lib"; //replace name with you lib name

function App() {
return <Button text="Button" />;
}

export default App;

After running the npm run dev command, you can test the locally published package.

Using the Published Library in Another Project

Create a directory named example and then run the following command to initialize a React app:

npm create vite@latest . -- --template react-ts

Using Npm Registry

To install a package from the npm registry, you can use the following command:

npm install sld-ui-lib

This command will install the package named sld-ui-lib from the npm registry.

Using Github Registry

To obtain a personal access token from GitHub for accessing packages from the GitHub Package Registry, follow these steps:

  1. Go to GitHub and sign in to your account.
  2. Click on your profile picture in the top right corner and select Settings.
  3. In the left sidebar, click on Developer settings.
  4. Under Access tokens, click on Personal access tokens.
  5. Next, click on the Generate new token button.
  6. Provide a name for your token in the Token name field.
  7. Under Select scopes, make sure to select the necessary permissions required for your use case. To access packages from the GitHub Package Registry, you typically need read:packages permissions.
  8. After configuring the settings, click the Generate token button at the bottom of the page.
  9. GitHub will generate a personal access token for you.

You can use this personal access token in your npm or yarn configuration when authenticating with the GitHub Package Registry.

To install a package from the GitHub package registry, create a .npmrc file in your project and add the below configuration. Here, replace GITHUB_USER_NAME with your GitHub username.

@GITHUB_USER_NAME:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

We shouldn't include the access token directly in this file, as it is tracked by Git. Instead, open a terminal and set an environment variable for your npm token. To do that, run the following command with your actual token:

export NPM_TOKEN=YOUR_TOKEN

You may want to add this export command to your shell profile (e.g., .bashrc, .zshrc) to make it persistent.

When you run npm commands, it will automatically substitute ${NPM_TOKEN} with the actual token from the environment variable.

Replace your-username with your GitHub username and package-name with the library name and run it. It will install the package from the registry.

npm install @your-username/package-name

Deploying a Storybook for Team Access

We are going to use Netlify for the deployment of our Storybook.

First, create an account on Netlify with GitHub and select the repository. Now, add the configuration as shown in the image below.

Here, we are running npm run build-storybook to generate static files for Storybook, which will be placed in the storybook-static directory.

Click ‘deploy,’ and Storybook is successfully deployed on Netlify.

Wrapping Up

This comprehensive guide covered essential tools and best practices for building a high-quality React component library — from project setup and testing to publishing, deployment, and local testing. Following these steps will enable you to create reusable, robust React components suitable for use across projects and teams. Whether building an open-source library or internal UI toolkit, you now have a solid foundation to develop shareable React components.

Feel free to adapt and extend these practices to suit the specific needs of your component library project. Happy coding!

For more updates on the latest tools and technologies, follow the Simform Engineering blog.

Follow Us: Twitter | LinkedIn

--

--