How to build a component library with React and TypeScript
Megan Lee
Posted on February 20, 2024
Written by Yan Sun✏️
React has become a top choice for building web applications. It enables us to easily build reusable UI components. Meanwhile, TypeScript, a statically typed superset of JavaScript, brings type safety, enhancing both code quality and developer productivity.
Component libraries are collections of pre-built, reusable, self-contained UI elements. These elements, often called components, encapsulate specific functionalities, making it easy to reuse them across different parts of an application or in multiple projects. This saves time and lets you avoid writing the same code over and over. In large project teams, component libraries provide a shared foundation so developers can work on different parts of a project simultaneously, knowing that they are using the same set of components.
By building component libraries with React and TypeScript together, we're not only making our development more efficient with reusable components, but we're also avoiding some common mistakes that can happen in other languages without strong typing. This combination lets us create a library that's flexible, scalable, and trustworthy.
In this blog, we'll delve into creating a React component library to make our React project maintainable and type-safe. We will walk through creating components, leveraging TypeScript for enhanced type safety, integrating Storybook for component visualization, and testing strategies that ensure our library stays reliable over time.
Setting up the development environment
Before we start, let's ensure our development environment is set up for success. Here are the prerequisites:
-
Node.js and npm: Download and install the latest Node LTS version on the local machine from nodejs.org. After installation, run the following command to check the Node and npm versions:
bash node -v npm -v
Code editor: Choose a code editor of your preference. For this tutorial, we'll assume you're using Visual Studio Code
Initializing a React project with TypeScript
We can use create-react-app
with the TypeScript template to initiate a new React project. create-react-app
is a command-line tool that sets up a new React application with a predefined project structure and build configurations.
Open a terminal and run the following command to create a new React app with the TypeScript template:
// create a new React project with the TypeScript template
npx create-react-app smart-ui --template typescript
The above command initializes a new React application by creating a new project structure, setting up essential configurations, and installing dependencies for a smooth development experience.
Building the component library
When designing the architecture of our component library, it is essential to consider these best practices:
- Single responsibility principle (SRP): Each component should have a single responsibility. Avoid creating monolithic components that handle too many tasks. This principle promotes reusability and makes components easier to understand and maintain
- Separation of concerns: Separation of concerns is the principle of dividing a program into distinct sections, each addressing a specific functionality aspect. When designing a component library, we should consider separate concerns such as logic, styling, and presentation
Directory structure
Following the above design practices, we will use a nested folder structure for better organization. As shown below, we group different components into different directories. In each component directory, we use separate files for the component's logic, styling, and testing concerns:
src/
├── components/
├── Button/
├── Button.tsx
├── Button.css
├── Button.test.ts
└── ...
├── Header/
├── Header.tsx
├── Header.css
├── Header.test.ts
└── ...
└── ...
Component library naming conventions
We adopt consistent naming conventions for our component library. The key is to choose clear and descriptive names that reflect the component's purpose. Below are some guidelines to follow:
- Use PascalCase: Adopt PascalCase for naming our component class and file names. This convention capitalizes the first letter of each word, and there are no spaces or underscores
- Prefix for uniqueness: We will add a prefix to the component names to ensure uniqueness, especially if the library might be used in projects alongside other libraries
- Prefer full words: We will use full words instead of acronyms to avoid ambiguous meanings
Creating a new component library
Now, let's create our first component library: smart-ui
. We will also add a new SmartRating
component in the library.
In the previous section, we used create-react-app
to create a skeleton of a React application. It is a good starter for a regular React app, but many of the artifacts are not necessary for a React component library.
We can clean the previously generated project assets or create a new component library from scratch. In the article, we will start from scratch.
Create the skeleton of the component library
Firstly, we create a new folder and a package.json
file by running the following command:
// create a new folder
mkdir smart-ui
// init a package.json
cd smart-ui
npm init
Running the npm init
command will prompt you to answer several questions. Accept the default for all questions and a basic package.json
will be generated.
Next, we run the following command to install the dependencies for React and TypeScript:
npm i react typescript @types/react tslib --save-dev
After the command is completed, these dependencies are added to the package.json
file.
To configure the TypeScript options, we need to create a tsconfig.json
file. We can initialize it with the following command from the project's root directory:
npx tsc -init
The newly generated tsconfig.json
file contains a set of default options for TypeScript. We will need to replace the file contents with the following settings:
{
"compilerOptions": {
"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,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
Some important settings are:
-
"target": "es5":
Specifies that the TypeScript compiler will generate code compatible with ECMAScript 5 -
"lib": ["dom", "dom.iterable", "esnext"]
: Includes necessary libraries, such as the Document Object Model (DOM) -
"skipLibCheck": true
: Skips type checking of declaration files (.d.ts), potentially speeding up compilation in large projects with numerous external dependencies -
"module": "esnext"
: Indicates that the module system for code compiling is ES6 and above -
"jsx": "react-jsx"
: Specifies the syntax for JSX (React's JavaScript XML)."react-jsx"
indicates that TypeScript should use React's JSX syntax -
"Include": ["src"]
: Specifies that TypeScript should include files from thesrc
directory
Now, we can create the project skeleton by adding the following folders:
mkdir src
cd src
mkdir components
cd components
mkdir smartrating
Create a SmartRating
component
After the folder structure is created, we can add the files for the new SmartRating
component:
-
SmartRating.tsx
-
SmartRating.css
-
SmartRating.types.ts
In SmartRating.types.ts
, we declare a TypeScript interface named SmartRatingProps
representing the properties accepted by the component:
export interface SmartRatingProps {
testIdPrefix: string;
title?: string;
theme: "primary" | "secondary";
disabled?: boolean;
}
SmartRating.css
includes the styles for the component:
body {
padding: 100px;
font-size: large;
text-align: left;
}
span {
margin-left: 10px;
background-color: transparent;
border: none;
outline: none;
cursor: pointer;
:hover {
color: grey;
}
}
.star{
font-size: large;
}
.starActive {
color: red;
}
.starInactive {
color: #ccc;
}
.rating-secondary {
background-color: black;
color: white;
padding:6px;
}
SmartRating.tsx
is the SmartRating
component file. Here are its contents:
import React, { useState } from "react";
import "./SmartRating.css";
import { SmartRatingProps } from "./SmartRating.types";
const SmartRating: React.FC<SmartRatingProps> = (props) => {
const stars = Array.from({ length: 5 }, (_, i) => i + 1);
const [rating, setRating] = useState(0);
return (
<div className={`star-rating rating-${props.theme}`}>
<h1>{props.title}</h1>
{stars.map((star, index) => {
const starCss = star <= rating ? "starActive" : "starInactive";
return (
<button
disabled={props.disabled}
data-testid={`${props.testIdPrefix}-${index}`}
key={star}
className={`${starCss}`}
onClick={() => setRating(star)}
>
<span className="star">★</span>
</button>
);
})}
</div>
);
};
export default SmartRating;
The above code defines a React component called SmartRating
that takes properties specified in SmartRatingProps
. It renders a star rating UI, allowing users to click on stars to set a rating. The component structure includes a title
, a disabled
state, and a theme
specified by the SmartRatingProps
.
Add index.ts
Next, we need to create the index.ts
files. The index.ts
file consolidates exports, providing a centralized entry point for the component library. It also simplifies imports for the consumer.
We create an index.ts
file at each level of folders to make the library export easier. For instance, in components/index.ts
, we can add a new export
to index.ts
when a new component is added, without needing to change src/index.ts
:
// src/components/smartrating/index.ts
export {default} from './SmartRating';
// src/components/index.ts
export * from './smartrating';
// src/index.ts
export * from './components';
The project structure looks like the one below:
Rollup for bundling and packaging
We’ll use Rollup to simplify the process of bundling and packaging our React component library. Rollup is a JavaScript module bundler that packages and optimizes code for production.
Rollup is particularly good at tree-shaking to remove unused code, making it well-suited for libraries where minimizing the bundle size is critical. It also offers flexibility in generating different output formats (CommonJS, ES module, UMD, etc.), allowing library authors to cater to various project setups and environments.
To enhance functionality, Rollup uses plugins to handle tasks like transpilation, minification, and resolving external dependencies. We need to install Rollup and its plugins to configure our library using the command below:
npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-peer-deps-external @rollup/plugin-terser rollup-plugin-dts --save-dev
To customize Rollup's bundling and processing behavior for our project, create a rollup.config.js
file at the root of the library project:
// rollup.config.js
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"],
},
{
input: "src/index.ts",
output: [{ file: "dist/types.d.ts", format: "es" }],
plugins: [dts.default()],
},
];
The above configuration file defines two bundles for a TypeScript library.
The first bundle, specified by the first object in the array, targets CommonJS (cjs) and ECMAScript Module (ESM) formats, creating separate files specified by the main
and module
entries in the package.json
. It includes plugins for handling external dependencies, resolving modules, transpiling TypeScript, and minifying the output with terser
.
The second bundle, specified by the second object in the array, generates a type declaration file (types.d.ts
) using the dts
plugin, providing TypeScript type information for the library.
Another change to set up Rollup is to add the following entries to the package.json
file:
…
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
…
"scripts": {
"rollup": "rollup -c --bundleConfigAsCjs",
...
}
Here is the explanation of the additional settings:
-
"main": "dist/cjs/index.js"
: Specifies the CommonJS entry point for the library, typically used by Node.js environments -
"module": "dist/esm/index.js"
: Indicates the ECMAScript Module entry point for modern JavaScript environments, providing an optimized library version for bundlers that support ES modules -
"types": "dist/index.d.ts"
: Points to the TypeScript type declaration file, providing type information for the library -
"scripts": {"rollup": "rollup -c --bundleConfigAsCjs", ...}
: Defines a script command that runs the Rollup bundler using a configuration file (-c)
and an additional flag (--bundleConfigAsCjs
) suggesting bundling for CommonJS. This script is used for generating the CommonJS bundle of the library
Add CSS support
In our library, we’ll use a CSS file to separate the styles away from the component file by default.
Rollup doesn't know how to process the CSS file. To enable Rollup to handle CSS files, we need to add the plugin named rollup-plugin-postcss
. Run the following command to install it:
npm install rollup-plugin-postcss --save-dev
We also need to change the Rollup configuration for CSS support in rollup.config.js
:
...
// add the CSS plugin support
import postcss from "rollup-plugin-postcss";
export default [
{
input: "src/index.ts",
output: [
{
...
],
plugins: [
...
// add CSS support
postcss(),
],
...
},
{
...
// add CSS support
external: [/\.css$/],
},
];
Now, we can build the library using the Rollup script command:
npm run rollup
After running the above command, we should see a new dist
directory created in the root directory. The new directory contains the generated library artifacts.
Leveraging TypeScript for type safety
When creating the SmartRating
component, TypeScript is crucial in ensuring component type safety. For example, we define the rating component props as shown below:
// SmartRating.types.ts
export interface SmartRatingProps {
testIdPrefix: string;
title?: string;
theme: "primary" | "secondary";
disabled?: boolean;
size?: "small" | "medium" | "large";
}
// SmartRating.tsx
const SmartRating: React.FC<SmartRatingProps> = (props) => {...}
We define the TypeScript interface SmartRatingProps
. This interface specifies the expected shape of the component's props, ensuring clarity and enforcing specific types.
The SmartRating.tsx
file uses the defined interface in the React.FC
(Functional Component) declaration. TypeScript ensures that the provided props
parameter adheres to the defined SmartRatingProps
interface, preventing potential runtime errors related to incorrect prop types.
For example, if we remove an existing props
(i.e., theme
) and forget to update the component file, the TypeScript compiler will throw the following error at compile time:
>Property 'theme' does not exist on type 'SmartRatingProps'.ts(2339)
TypeScript enforces strict typing for props, ensuring only the specified types are allowed. With the help of union types, we can define specific values for certain props, providing a clear set of options. For example, theme
must be either primary
or secondary
. If we have a typo, the TypeScript compiler will catch it and throw the error immediately.
The SmartRatingProps
interface also serves as documentation, making it clear to developers what props the component expects and their respective types. When we use IDEs with TypeScript support (i.e., VS Code), the IDE tools provide autocompletion and IntelliSense for props, enhancing the developer experience and reducing the chance of typos.
Integrating Storybook into our library
Now, the core functionality of our library is ready. It is time to add Storybook to visualize our new component to ensure it works as intended. Storybook is a development environment for UI components, allowing isolated and interactive development, testing, and documentation.
Setting up Storybook
Setting up Storybook is straightforward. Run the following command in the project root directory:
npx sb init
The above command will prompt us to select a project builder; we can accept the default Vite option.
After completing the command, it configures Storybook with default settings and creates necessary files and folders (e.g., .storybook
directory) for Storybook integration. It also generates a stories
directory within the src
directory, containing pre-built templates that serve as examples for creating our own stories.
Additionally, we also observe new dependencies and script commands in package.json:
// package.json
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
Create a story for the SmartRating
component
Storybook is successfully configured. Now, it’s time to create our first story.
Create a new file in the smartrating
folder named SmartRating.stories.tsx
:
// smartRating.stories.tsx
import { StoryFn, Meta } from "@storybook/react";
import SmartRating from "./SmartRating";
export default {
title: "ReactComponentLibrary/Rating",
component: SmartRating,
} as Meta<typeof SmartRating>;
const Template: StoryFn<typeof SmartRating> = (args) => <SmartRating {...args} />;
export const RatingTest = Template.bind({});
RatingTest.args = {
title: "Default theme",
theme: "primary",
testIdPrefix: "rating",
};
export const RatingSecondary = Template.bind({});
RatingSecondary.args = {
title: "Secondary theme",
theme: "secondary",
testIdPrefix: "rating",
};
In this story, we define two stories: RatingTest
and RatingSecondary
. They showcase the component in different scenarios, helping visualize and test its behavior within the Storybook environment.
To test the stories, run the following command:
npm run storybook
Running the command will open a new browser tab with the component rendered in the Storybook UI as below:
We can switch between our two stories to observe the different looks and behavior of our components. Here, we just touch the surface of Storybook's features. For a deeper exploration, check out “Storybook adoption guide: Overview, examples, and alternatives.”
Testing with Jest and React Testing Library
Adding tests to our component library ensures that components behave as expected, helping catch regressions and ensuring ongoing functionality as the library evolves.
Jest, combined with the React Testing Library, offers a powerful testing solution for React components. To start, install the following dependencies:
// install Jest and testing-library
npm install @testing-library/react jest @types/jest jest-environment-jsdom --save-dev
// install babel and its related plugins
npm install @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript babel-jest --save-dev
// install identity-obj-proxy
npm install identity-obj-proxy -save-dev
The above commands install the React Testing Library, Jest, Babel, and its Jest dependencies for transpiling and testing React components with TypeScript. We also install identity-obj-proxy
, which allows Jest to treat all types of imports (CSS, LESS, and SCSS) as generic objects. Specifically, we can configure it for CSS files to prevent any errors in testing.
Next, we need to add configuration files for Jest and Babel. Create the jest.config.js
and babel.config.js
in the root project directory with the following contents:
// jest.config.js
module.exports = {
testEnvironment: "jsdom",
moduleNameMapper: {
".(css|less|scss)$": "identity-obj-proxy",
},
};
// babel.config.js
module.exports = {
presets: [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript",
],
};
In jest.config.js
, we set the Jest test environment to jsdom
for simulating a browser environment for testing. Then, we set the moduleNameMapper
property for CSS, LESS, and SCSS file imports in tests, allowing them to be mocked without actual styling.
In babel.config.js
, we configure Babel presets for transpiling JavaScript, React, and TypeScript code in the project, ensuring compatibility and proper compilation during testing.
Now, we can create a test file named SmartRating.test.tsx
in the smartrating
directory:
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import SmartRating from "./SmartRating";
describe("SmartRating", () => {
test("renders the Rating component", () => {
render(<SmartRating title="default" theme="primary" testIdPrefix="rating" />);
expect(screen.getByRole("heading").innerHTML).toEqual("default");
expect(screen.getAllByRole("button", { hidden: true }).length).toEqual(5);
});
test("click the 5 star rating", async () => {
const stars = [0, 1, 2, 3, 4];
render(<SmartRating title="default" theme="primary" testIdPrefix="rating" />);
stars.forEach(async (star) => {
const element = screen.getByTestId("rating-" + star);
userEvent.click(element);
await waitFor(() => expect(element.className).toBe("starActive"));
});
});
});
This test uses the React Testing Library and user-event
to test the SmartRating
component. There are two tests:
- The first test renders the component with specific props and asserts that the rendered component has the correct title and contains five buttons representing the stars
- The second test simulates a user clicking each star in the
SmartRating
component asynchronously, then it verifies that clicking each star activates it by checking the change in the star's CSS class tostarActive
The last step is to add the following script command in package.json
:
// package.json
"scripts": {
"test": "jest",
…
}
We can run the test with this command:
npm run test
We should see the two green ticks for the tests:
Building and managing the component library
Dependency management
Dependencies are declarations of external packages or libraries essential for the project's functionality, and effective dependency management is vital when constructing a component library.
Besides the typical dependencies, there are other types of dependencies including:
- Peer dependencies: These are external dependencies that our library expects its consumers to provide. They are not bundled with the library, and instead, the consumer is expected to install them as direct dependencies
- Dev dependencies: Dev dependencies are necessary during the development and testing phase but are not required for the runtime functionality of the project or library. For example, those testing libraries are dev dependencies
We currently have all the dependencies under the devDependencies
in package.json
. To manage the dependencies better, we want to move the following dependencies into peerDependencies
:
// package.json
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
Moving react
and react-dom
to peer dependencies in our library is beneficial because it allows consumers of the library to use their version of React. This flexibility prevents version conflicts, fostering a more seamless integration experience.
Multiple index.ts
files
In our component library, we use multiple index.ts
files in different levels of directories. They serve specific purposes, making our library exporting easier to organize:
- At root directory: At the library's root, an
index.ts
file can serve as the main entry point, re-exporting components or modules from various directories. This consolidates the library's public API - At components directory: Use
index.ts
files within the component directory to export components or modules, providing a clean and organized entry point - At individual component directory: When organizing components into nested directories, each subdirectory can have its
index.ts
file, aggregating and exporting components within that directory. This modular structure aids in navigation
By organizing code with multiple index.ts
files, the component library becomes more modular and maintainable, simplifying both development and integration for consumers.
Packaging and publishing to npm
Finally, we are ready to publish our component library as an npm package. Publishing the library allows it to be shared between the different development teams or the wider development communities.
Update version
Before we start the publishing process, we need to update the library version in package.json following the SemVer convention.
Semantic Versioning (SemVer) is a versioning convention for software that uses three numbers (major, minor, patch) to communicate the nature of changes:
- major for backward-incompatible
- minor for backward-compatible additions
- patch for backward-compatible bug fixes
Here, we set the package version to be 1.0.0
. In future publishing, we will update the version according to the SemVer convention:
{
"name": "smart-ui",
"version": "1.0.0",
...
}
Login and publish to npm
We begin the publishing process by logging in to npm:
npm login
The command will prompt us to enter the username, password, and email. After logging in to npm, we can run the following command to publish our library:
npm publish --access public
This command publishes the package to the npm registry with public access, making it available for installation by anyone.
We can use the following command to verify that our library has been published successfully:
npm view smart-ui
Congratulations! Our new React component library is published and available for download.
Conclusion
In this article, we walked through the process of building, packaging, and publishing a React component library using TypeScript. Developing a component library with React and TypeScript offers significant advantages, including enhanced reusability, improved type safety, centralized documentation, and potential community contributions.
This does come with its challenges. The initial setup and tool configuration can be complex, and challenges like managing versioning, ensuring backward compatibility, handling dependencies, and conducting thorough testing require careful consideration.
Despite these challenges, the benefits of creating a component library with React and TypeScript make it a valuable undertaking for achieving scalable and consistent UI development.
I hope this guide is useful to you. Please feel free to share your thoughts or leave a comment below. You can find the example source code on GitHub.
Get set up with LogRocket's modern React error tracking in minutes:
- Visit https://logrocket.com/signup/ to get an app ID.
- Install LogRocket via NPM or script tag.
LogRocket.init()
must be called client-side, not server-side.
NPM:
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
Script Tag:
Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Posted on February 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 14, 2024