How to build a component library with React and TypeScript

Original post: How to build a component library with React and TypeScript – LogRocket Blog

Editor’s note: This guide to building a component library with React and TypeScript was last updated on 27 April 2023 to reflect recent changes to React and to include new sections defining and exploring the benefits of component libraries. This article was reviewed by Timonwa Akintokun.

React is still the most famous frontend library in the web developer community. When Meta (previously Facebook) open sourced React in late 2013, it made a huge impact on single-page applications (SPAs) by introducing concepts like the virtual DOM, breaking the UI into components, and immutability.

React has evolved a lot in the past few years, and with the introduction of TypeScript support, Hooks, Suspense, and React Server Components, we can assume that React will still hold the crown for best frontend tool. React has opened the doors to some awesome projects like Next.js, Gatsby, and Remix, which focus more on improving the developer experience.

Along with this robust React ecosystem is a wide variety of component libraries, like Material UIChakra UI, and React-Bootstrap. When you come across these awesome tools and libraries, have you ever wondered how they are made? Or what would it take to create your own UI component library with React?

In this article, we’ll learn what a component library is and how to build our component library with React and TypeScript. We’ll also publish it to npm so that you, too, can contribute to React’s ever-growing community of projects. But first, let’s talk about what a component library is.

What is a component library?

A component library is a collection of reusable UI components that can be shared across different projects and applications, typically implemented in code. Component libraries ensure UI consistency across different applications and teams. These components can be anything from buttons and forms to complex UI elements like carousels or calendars. By using a component library, developers can save time and effort by not having to reinvent the wheel each time they create a new application. Instead, they can reuse pre-built, well-tested components or features designed to work together seamlessly.

Why build a component library?

There are several reasons why one might want to build a component library. First, a component library provides a set of pre-built UI components that can be used across multiple projects. It ensures consistency in the design and behavior of the components and saves time and effort by not having to build the same components from scratch for every project.

Component libraries also allow for easy scaling of UI development and allow for easier maintenance and updates of existing features. Additionally, component libraries promote collaboration and communication among team members by providing a shared library of components that all members can easily access and use. This ensures that everyone is on the same page regarding the design and functionality of the components.

Brand consistency is important. Fortunately, component libraries help maintain brand consistency by providing a set of pre-defined styles and components that adhere to the brand guidelines. This ensures all components, features, and projects align with the brand image and messaging.

Accessibility is essential to all aspects of the web, and component libraries can also ensure accessibility compliance by providing pre-built components that follow accessibility guidelines and best practices. This helps to ensure that all users can access and use the components.

Required tools and project structure

Creating a UI library is quite different from creating a web application. Instead of using popular tools like Next.js or Create React App, we have to start from scratch. To build this UI library, we’ll use the following tools.

TypeScript

There’s no doubt that TypeScript is the go-to method when developing with JavaScript. TypeScript will save us a ton of time because it introduces types and compile time checking. In addition, TypeScript will be helpful when we build components for our library because we will be dealing with props in React components, and defining types will avoid issues when passing props to the components.

Rollup

We will need some custom configurations for our library. Rollup is an excellent middle ground between popular JavaScript bundlers because it is more customizable than Parcel but requires less configuration effort than webpack.

Storybook

We are building components, so we need a way of visualizing them in an isolated environment. Storybook does that for us because it spins up a separate playground for UI components.

Jest and React Testing Library

Jest is a complete testing framework with a test runner plus an assertion and mocking library. It also lets the user create snapshot tests for components, which is ideal when building components in React. React Testing Library helps us write tests as if a real user is working on the elements.

To organize our library, we will create a directory for each component named after the component itself. This directory will contain all the necessary files for that component, including a type definition file, a story file for documentation purposes, and a test file. Additionally, the component file(s) will be included, as well as an index.ts file to export all the components within that directory.

All component directories will be grouped into a single directory called "components", which will be located in the "src" directory, which is, in turn, located in the "root" directory of our library.

Building the component library

To build our component library, we start by creating a local repository on our system. Create an empty folder and give it the name of your choice for your library. Open the folder in your preferred code editor, and initialize it as a JavaScript project by running the following command in the terminal:

npm init

This command will generate a package.json file in the root directory and prompt us for information about the project, such as the project name, version, description, author, license, repository, and scripts. Now, install React and TypeScript to our project through the following command:

npm i -D react typescript @types/react

Notice that we are passing the flag -D because we need to install it as devDependencies rather than a project dependency; we’ll need those dependencies when we are building the bundle.

Please note that this library will be used in a React project in the future. So, before publishing the library, it’s important to move some of the packages currently installed as "devDependencies" to "peerDependencies" or "dependencies". The peerDependencies will be packages that the library needs to function properly in a project, while the dependencies will be packages that are required for the library to work as intended. It’s recommended to move these packages before publishing the library to ensure that it works seamlessly with other projects.

Creating components

Now that we have added React and TypeScript, we can start creating our components, beginning with a button. All files relating to our Button component will be placed inside the src/components/Button directory. Because this is a TypeScript project, we first create the type definitions for the components, which I’m naming Button.types.ts:

import { MouseEventHandler } from "react";

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

This file consists of all the props of the button, including the onClick event, and the MouseEventHandler tells that the onChange prop is responsible for a mouse event. We will use these props in the button component to add or enable different properties. Now, let’s move on to the creation of the button component itself.

Integrating styled-components for a button component

In this project, we will not be using regular CSS. Instead, we will be using CSS-in-JS. CSS-in-JS provides many benefits over regular CSS, for example:

  • Reusability: Because CSS-in-JS is written in JavaScript, the styles you define will be reusable JavaScript objects, and you can even extend their properties
  • Encapsulation: CSS-in-JS scopes are generated by unique selectors that prevent styles from leaking into other components
  • Dynamic: CSS-in-JS will allow you to dynamically change the properties of the styling depending on the value that the variables hold

There are many ways to write CSS-in-JS in your component, but for this tutorial, we will be using one of the most famous libraries — styled-components. You can run the following commands to install the styled-components dependencies along with the type definitions for TypeScript support:

npm install -D styled-components @types/styled-components

Now, let’s create our button component. Still, in the src/components/Button folder, create a file called Button.tsx and include the following code:

import React from "react";
import styled from "styled-components";
import { ButtonProps } from "./Button.types";

const StyledButton = styled.button<ButtonProps>`
  border: 0;
  line-height: 1;
  font-size: 15px;
  cursor: pointer;
  font-weight: 700;
  font-weight: bold;
  border-radius: 3px;
  display: inline-block;
  padding: ${(props) =>
    props.size === "small"
      ? "7px 25px 8px"
      : props.size === "medium"
      ? "9px 30px 11px"
      : "14px 30px 16px"};
  color: ${(props) => (props.primary ? "#1b116e" : "#ffffff")};
  background-color: ${(props) => (props.primary ? "#6bedb5" : "#1b116e")};
  opacity: ${(props) => (props.disabled ? 0.5 : 1)};
  &:hover {
    background-color: ${(props) => (props.primary ? "#55bd90" : "#6bedb5")};
  }
  &:active {
    border: solid 2px #1b116e;
    padding: ${(props) =>
      props.size === "small"
        ? "5px 23px 6px"
        : props.size === "medium"
        ? "7px 28px 9px"
        : "12px 28px 14px"};
  }
`;

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;

If you go through the code above, you will notice something special. We have defined a variable called StyledButton to which we assign styling properties through special tags called tagged template literals, a new JavaScript ES6 feature that enables you to define custom string interpolations.

These tagged templates are used to write the StyledButton variable as a React component that you can use like any other. Notice that we also have access to the component props inside these specially tagged templates. After that, we create a index.ts file and export our Button.tsx file in it like so:

export * from "./Button";

Building an input component

Now, let’s create an input component in the src/components/Input folder, starting first with its type definitions by creating a file called Input.types.ts:

import { ChangeEventHandler } from "react";

export interface InputProps {
  id?: string;
  label?: string;
  error?: boolean;
  message?: string;
  success?: boolean;
  disabled?: boolean;
  placeholder?: string;
  onChange?: ChangeEventHandler<HTMLInputElement>;
}

Like in the previous component, we have defined the prop attributes in the Input.types.ts file. We also have an onChange event and assigned ChangeEventHandler<HTMLInputElement> from React to it. This tells that the onChange prop is responsible for an input change event. Finally, let’s create the input component called Input.tsx:

import React, { FC, Fragment } from "react";
import styled from "styled-components";
import { InputProps } from "./Input.types";

const StyledInput = styled.input<InputProps>`
  height: 40px;
  width: 300px;
  border-radius: 3px;
  border: solid 2px
    ${(props) =>
      props.disabled
        ? "#e4e3ea"
        : props.error
        ? "#a9150b"
        : props.success
        ? "#067d68"
        : "#353637"};
  background-color: #fff;
  &:focus {
    border: solid 2px #1b116e;
  }
`;

const StyledLabel = styled.div<InputProps>`
  font-size: 14px;
  color: ${(props) => (props.disabled ? "#e4e3ea" : "#080808")};
  padding-bottom: 6px;
`;

const StyledMessage = styled.div<InputProps>`
  font-size: 14px;
  color: #a9150b8;
  padding-top: 4px;
`;

const StyledText = styled.p<InputProps>`
  margin: 0px;
  color: ${(props) =>
    props.disabled ? "#e4e3ea" : props.error ? "#a9150b" : "#080808"};
`;

const Input: FC<InputProps> = ({
  id,
  disabled,
  label,
  message,
  error,
  success,
  onChange,
  placeholder,
  ...props
}) => {
  return (
    <Fragment>
      <StyledLabel>
        <StyledText disabled={disabled} error={error}>
          {label}
        </StyledText>
      </StyledLabel>
      <StyledInput
        id={id}
        type="text"
        onChange={onChange}
        disabled={disabled}
        error={error}
        success={success}
        placeholder={placeholder}
        {...props}></StyledInput>
      <StyledMessage>
        <StyledText error={error}>{message}</StyledText>
      </StyledMessage>
    </Fragment>
  );
};

export default Input;

In the above code, you can see that we have defined several styled-components and wrapped them together through a React fragment. We use a fragment because it enables us to group multiple sibling components without introducing any extra elements in the DOM. This will come in handy because there won’t be any unnecessary markup in the rendered HTML of our components. We will also create a index.ts file in the Input folder and export our Input.tsx file in it like so:

export * from "./Input";

After this, we then go into our components folder and create another index.ts file in the src folder and export the Button and Input components within it:

export * from "./Button";
export * from "./Input";

And, go into our src folder, create another index.ts, and export the index.ts file in the components folder into it:

export * from "./components";

But why so many index.ts files and exports?

In a library, it’s important to structure our code in a way that allows other developers to import and use our components easily. Creating an index.ts file in each component folder and exporting the component from there will enable us to have a single entry point for that component instead of importing the component file directly. For example, instead of having to import Button.tsx directly in our code like this:

import { Button } from "./components/Button/Button";

We can import it through the index.ts file in the src folder:

import { Button } from "your-library-name";

This makes the code more readable and allows us to change the location or name of the component file without changing all the import statements. Additionally, by exporting all the components in the src/index.ts file, we can provide a single entry point for all the components in our library.

This allows developers to import all the components simultaneously without having to import each separately. Overall, creating these index.ts files and exports helps improve the structure and organization of our code and makes it more convenient for other developers to use our library.

Configuring TypeScript and Rollup

Now, it’s time to configure TypeScript with Rollup. We are using TypeScript to build the components. In order to build the library as a module, we will need to configure Rollup along with it. In a previous step, we installed TypeScript to our project, so now we just need to add the TypeScript configurations.

For this, we can use TypeScript’s CLI to generate the file. Create a tsconfig.json file in the root directory and paste the code below into it so that it will fit our project scenario:

{
  "compilerOptions": {
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "jsx": "react",
    "module": "ESNext",
    "declaration": true,
    "declarationDir": "types",
    "sourceMap": true,
    "outDir": "dist",
    "moduleResolution": "node",
    "emitDeclarationOnly": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": [
    "dist",
    "node_modules",
    "src/**/*.test.tsx",
    "src/**/*.stories.tsx"
  ]
}

In the above code, you can see that we have added configurations like "skipLibCheck": true, which skips type checking declaration files. This can save time during compilation. Another special configuration is "module": "ESNext", which indicates that we will be compiling the code into the latest version of JavaScript (ES6 and above, so you can use import statements).

The attribute "sourceMap": true tells the compiler that we want source map generation. A source map is a file that maps the transformed source to the original source, which enables the browser to present the reconstructed original in the debugger. You will also notice that we have defined an "exclude" section in order to tell TypeScript to avoid transpiling specified directories and files so that it won’t transpile the tests and stories of our library.

Now that we have configured TypeScript, let’s begin configuring Rollup. First, install Rollup as a devDependencies project through the following command:

npm i -D rollup

However, installing Rollup is not enough for our project because we will need additional features, like the following:

  • Bundling to CommonJS format
  • Resolving third-party dependencies in node_modules
  • Transpiling our TypeScript code to JavaScript
  • Preventing bundling of peerDependencies
  • Minifying the final bundle
  • Generating type files (.d.ts), that provide TypeScript type information about the components in our project

The above features will come in handy when we finally build and use the library as a package. CommonJS is a specification standard used in Node.js, and modules are loaded synchronously and processed in the order the JavaScript runtime finds them. Luckily, there are several plugins for Rollup that we can use for the above requirements, which can be installed through the following command:

npm i -D @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-peer-deps-external rollup-plugin-terser rollup-plugin-dts

If you encounter a "Could not resolve dependency" error when configuring the packages above, you can either downgrade Rollup to v3.0.0, downgrade the version of the plugins that have the dependency issue to the version suggested in the terminal, or try running the command with the --force option.

Now that Rollup and its awesome plugins are installed, let’s move on to its configuration. Create a rollup.config.js file in the root of our project and add the following:

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 [
  {
    input: "src/index.ts",
    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"],
  },
  {
    input: "src/index.ts",
    output: [{ file: "dist/types.d.ts", format: "es" }],
    plugins: [dts.default()],
  },
];

In the code above, you can see that we are building our library with both CommonJS and ES modules. This will allow our component to have more compatibility in projects with different JavaScript versions. ES modules allow us to use named exports, better static analysis, tree shaking, and browser support. Next, we have to define the paths in package.json for both ES modules, CommonJS, and TypeScript declaration:

"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",

Now that we have added the necessary configurations, let’s add the build command in the script section of the package.json file:

"build": "rollup -c --bundleConfigAsCjs",

Now, you can build the project by using the following command from your terminal:

npm run build

The above command will generate a directory in the root directory called dist, which is our build directory defined in the Rollup configurations.

Integrating Storybook into our library

The next step is to integrate Storybook into our library. Storybook is an open source tool for building UI components and pages in isolation. Because we are building a component library, it will help us to render our components in the browser to see how they behave under particular states or viewpoints.

Configuring Storybook is quite easy thanks to its CLI, which is smart enough to recognize a project type and generate the necessary configurations with the following command:

npx sb init

When you execute the above code on your terminal, it will generate the directories .storybook in the root directory and stories in the components folder. The .storybook directory will hold all the configurations and stories will hold the stories for your component. A story is a unit that captures the rendered state of a UI component.

The above command will also add some scripts for our package.json file as well, which will allow us to build the Storybook, as shown below:

  "scripts": {
   ….
   "storybook": "storybook dev -p 6006",
   "build-storybook": "storybook build"
  }

In this project, we will adopt a different approach and forgo the use of the stories directory. Instead, we will write each story for our components within the components folder of the corresponding component. This strategy promotes better organization of the library, as all the files (including types, tests, and stories) related to a particular component will be housed in one location.

Writing stories for the button component

So, deleting the current stories created by Storybook on installation, we create our own by writing our first story for the button component, Button.stories.tsx, within the Button folder:

import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import Button from "./Button";

const meta: Meta<typeof Button> = {
  component: Button,
  title: "Marbella/Button",
  argTypes: {},
};
export default meta;

type Story = StoryObj<typeof Button>;

export const Primary: Story = (args) => (
  <Button data-testId="InputField-id" {...args} />
);
Primary.args = {
  primary: true,
  disabled: false,
  text: "Primary",
};

export const Secondary: Story = (args) => (
  <Button data-testId="InputField-id" {...args} />
);
Secondary.args = {
  primary: false,
  disabled: false,
  text: "Secondary",
};

export const Disabled: Story = (args) => (
  <Button data-testId="InputField-id" {...args} />
);
Disabled.args = {
  primary: false,
  disabled: true,
  text: "Disabled",
};

export const Small: Story = (args) => (
  <Button data-testId="InputField-id" {...args} />
);
Small.args = {
  primary: true,
  disabled: false,
  size: "small",
  text: "Small",
};

export const Medium: Story = (args) => (
  <Button data-testId="InputField-id" {...args} />
);
Medium.args = {
  primary: true,
  disabled: false,
  size: "medium",
  text: "Medium",
};

export const Large: Story = (args) => (
  <Button data-testId="InputField-id" {...args} />
);
Large.args = {
  primary: true,
  disabled: false,
  size: "large",
  text: "Large",
};

In the above code, we imported the button component. Next, we created a story type from it to render different states of the button (like primarysecondary, and disabled) through type arguments. This template allows us to see how the button component behaves according to the props we pass to the component. Next, we can create the story for the input component, Input.stories.tsx inside the Input folder:

import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import Input from "./Input";

const meta: Meta<typeof Input> = {
  component: Input,
  title: "Marbella/InputField",
  argTypes: {},
};
export default meta;

type Story = StoryObj<typeof Input>;

export const Primary: Story = (args) => (
  <Input data-testId="InputField-id" {...args} />
);
Primary.args = {
  error: false,
  disabled: false,
  label: "Primary",
};

export const Success: Story = (args) => (
  <Input data-testId="InputField-id" {...args} />
);
Success.args = {
  error: false,
  success: true,
  disabled: false,
  label: "Success",
};

export const Error: Story = (args) => (
  <Input data-testId="InputField-id" {...args} />
);
Error.args = {
  error: true,
  disabled: false,
  message: "Error",
};

export const Disabled: Story = (args) => (
  <Input data-testId="InputField-id" {...args} />
);
Disabled.args = {
  disabled: true,
  label: "Disabled",
};

In the above code, we have also defined the story type to indicate the state of the input component through props. Let’s see how these components are rendered in Storybook by running the following command:

npm run storybook

The above command will open a new tab on your browser and render the components inside the Storybook view, as shown below:

React Button Component Displayed in Storybook

In the above image, you can see that in the left sidebar, we have our components defined in the story, including the types. In the center, you can see our button component with the options we included in the last code block. It continues:

The React Input Component in Storybook

In this image, you can see the input component’s story types and properties. Now, you can play around with these options and see how the components behave in different scenarios. You can enhance these properties by installing some additional add-ons for Storybook.

You may face some issues when configuring Storybook due to missing dependencies like babel. If you face any issues, go through the logs and install only the necessary libraries, or try downgrading the version of Storybook.

Testing with Jest and React Testing Library

Now, it’s time to add some testing for our component. First, install the dependencies to configure Jest and React Testing Library. You can execute the following command to install the dependencies:

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

Here, we have installed Jest and testing-library along with several add-ons for them, like jest-dom and user-event, which enable Jest to render the components using the DOM for making assertions, and mock user events (like clickfocus, or lose focus).

After installing the dependencies, create a jest.config.js file to hold the configurations for the tests. You can also use the Jest CLI to generate this file by running the following command:

npx jest --init
React Testing With Jest CLI

The above image is the console output that you will get when you use the Jest CLI. Now, let’s start writing the tests, starting with the button component. In the Button folder, create a Button.test.tsx file and include the following code in it:

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

import Button from "./Button";

describe("Running Test for Marbella Button", () => {

  test("Check Button Disabled", () => {
    render(<Button text="Button marbella" disabled/>)
    expect(screen.getByRole('button',{name:"Button marbella"})).toBeDisabled();
  });
});

In the above code, we are rendering the button component and checking if we are rendering the properties we defined (in this case, disabling the button). This particular test will pass if the rendered button is disabled.

Next, we can test the input component. Also, create an Input.test.tsx file in the Input folder and include the following code in it:

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

import Input from "./Input";

describe("Running Test for Marbella Input", () => {

  test("Check placeholder in Input", () => {
    render(<Input placeholder="Hello marbella" />)
    expect(screen.getByPlaceholderText('Hello marbella')).toHaveAttribute('placeholder', 'Hello marbella');
  });

  test("renders the Input component", async () => {
    render(<Input placeholder="marbella" />);
    const input = screen.getByPlaceholderText("marbella") as HTMLInputElement;
    userEvent.type(input, "Hello world!");
    await waitFor(() => expect(input.value).toBe("Hello world!"));
  });
});

In the above code, we have two test scenarios: one will render the input component with a prop placeholder, and the other will mock a user event of typing inside the component. Let’s execute the test through the following command:

npm run test
Example of Output With Jest and React

The above image shows the output of running the test with some metrics. All the tests we wrote passed. Yay!

Modifying the package.json dependencies

Now that we have finished building our library, we need to adjust our dependencies. Some packages currently installed as devDependencies should be moved to either peerDependencies or dependencies. As I said earlier, PeerDependencies are packages that the library needs to function properly in a project, while dependencies are packages that are required for the library to work as intended.

So, we need to redeclare the prop-types and styled-components packages as dependencies and the React and react-dom packages as peerDependencies, like so:

"dependencies": {
    "prop-types": "^15.8.1",
    "styled-components": "^5.3.10"
  },
  "devDependencies": {
    ...
  },
  "peerDependencies": {
    "react": "^18.2.0",
    "react-dom": "18.2.0"
  }

Packaging and publishing to npm

We’re all done! Now, we can publish this library as an npm package. To create and publish a package on npm, you will need to create an account on npmjs. When you create an account, search for your package name in npmjs just to see if it already exists because there may be packages with similar names. Next, begin the process of publishing your package by running the following command in the terminal:

npm login

This will prompt you to enter the username and password of your npm account. Do so, then run the build command again just to build the package for the last time. After building we can publish it as a package through the following command:

npm publish --access public

After successfully publishing your library, you should be able to see the published package on npm in your profile. And your package will be available for everyone to download!

Conclusion

In this article, we built a React library with TypeScript and used CSS-in-JS with some tools like Rollup, Storybook, and Jest. The code for this project can be found in this GitHub repository.

There are many ways to create component libraries, and there are a lot of starter templates available for you to create one. But building your own library from scratch gives you the opportunity to add or use the tools that you love the most, and it gives you in-depth knowledge on how build tools work. Thank you for reading this article! I would like to hear your thoughts in the comment section.

Leave a Reply 0

Your email address will not be published. Required fields are marked *