Projects Blog

Build a React component library with TypeScript and Vite

Last updated on

In this guide we are going to set up a project for building and publishing a React component library with type declarations, step by step. For this purpose, we using Vite 6, React 19, TypeScript and Storybook.

Additionally, we will integrate essential code quality tools such as ESLint (updated to v9 flat config), Stylelint and Prettier.

Moreover, we are going to set up the testing environment using Vitest and React Testing Library.

You can find the template repository here.

This repository is only supporting ECMAScript modules, if you need a cjs demo refer to this branch of the repository.

Features

We are introducing the following features to enhance our project:

Initialize the Vite project

Execute the appropriate command corresponding to your package manager.

# npm 7+, extra double-dash is needed:
npm create vite@latest react-lib -- --template react-ts
# yarn
yarn create vite react-lib --template react-ts
# pnpm
pnpm create vite react-lib --template react-ts
# bun
bun create vite react-lib --template react-ts

Changing the project structure

Here’s our current structure, just after running the previous command.

📂 my-react-lib
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📄 App.css
│ ├─ 📄 index.css
│ ├─ 📄 vite-env.d.ts
│ ├─ 📄 App.tsx
│ └─ 📄 main.tsx
├─ 📄 index.html

We intend to turn this app project into a component library, so the next step involves deleting the Vite dev server-related files: App.css, index.css, App.tsx, main.tsx, and index.html.

📂 my-react-lib
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📄 App.css
│ ├─ 📄 index.css
│ ├─ 📄 vite-env.d.ts
│ ├─ 📄 App.tsx
│ └─ 📄 main.tsx
├─ 📄 index.html

Also, we can delete the dev and preview scripts from the package.json

package.json
{
// ...
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
}
}

And now, we are creating a /components folder under /src with our first component in it.

📂 my-react-lib
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📂 components
│ │ ├─ 📂 MyButton
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ ├─ 📂 MyCounter
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ └─ 📂 MyTitle
│ │ ├─ 📄 styles.module.css
│ │ └─ 📄 index.tsx
│ └─ 📄 vite-env.d.ts
├─ 📄 index.html

Under the component folder, in this case /components/MyButton, we create this two files:

index.tsx
import styles from './styles.module.css'
import clsx from 'clsx'
interface ComponentProps extends React.ComponentProps<'button'> {
primary?: boolean
size?: 'small' | 'medium' | 'large'
label: string
}
export function MyButton({ primary = false, size = 'medium', label, ...props }: ComponentProps) {
const style = clsx(styles.button, {
[styles['button--primary']]: primary,
[styles[`button--${size}`]]: size,
})
return (
<button
type='button'
className={style}
{...props}
>
{label}
</button>
)
}
styles.module.css
.button {
--bg-color: none;
background-color: var(--bg-color);
border-radius: 6px;
}
.button--primary {
--bg-color: crimson;
}
.button--small {
font-size: 1rem;
padding: 0.4rem 0.6rem;
}
.button--medium {
font-size: 1.2rem;
padding: 0.6rem 0.8rem;
}
.button--large {
font-size: 1.4rem;
padding: 0.8rem 1rem;
}

We have created our first component! 🥳

Now, we need the library entry point. For this purpose, we create a new file called main.ts under /src.

📂 my-react-lib
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📂 components
│ │ ├─ 📂 MyButton
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ ├─ 📂 MyCounter
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ └─ 📂 MyTitle
│ │ ├─ 📄 styles.module.css
│ │ └─ 📄 index.tsx
│ ├─ 📄 App.css
│ ├─ 📄 index.css
│ ├─ 📄 main.ts
│ ├─ 📄 vite-env.d.ts
│ ├─ 📄 App.tsx
│ └─ 📄 main.tsx
├─ 📄 index.html
main.ts
export { MyButton } from './components/MyButton/'
export { MyCounter } from './components/MyCounter/'
export { MyTitle } from './components/MyTitle/'

At this point, we’ve organized the project files, crafted our inaugural component, and established the library’s entry point. However, Vite seems unable to locate it. Let’s address this issue!

Vite in library mode

For bundling our library for distribution, we have to update the Vite configuration build.lib option.

Also, we are using the triple slash TypeScript directive to add within the compiler the vite/client types.

vite.config.ts
/// <reference types="vite/client" />
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
},
})

At this point, I suggest to install Node.js types to prevent TypeScript errors:

npm i -D @types/node

And then we should indicate Vite which dependencies are external and we don’t want to bundle into the library.

vite.config.ts
/// <reference types="vite/client" />
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
})

At this stage, upon running the build script, you will notice two issues:

Let’s handle this.

Adding styles

Vite has a problem with CSS modules: when building, there is not an import CSS line in the generated code, so people who use this library have to manually import them when using the components, as exposed in this issue.

This is not the behavior we desire.

For resolving this problem, we are going to use vite-plugin-lib-inject-css, a simple plugin that imports the processed styles into the generated chunks. So let’s install the package.

npm i -D vite-plugin-lib-inject-css

And then we need to add it as a plugin within vite.config.ts.

vite.config.ts
/// <reference types="vite/client" />
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({
plugins: [react(), libInjectCss()],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
})

At this point, if we run the build script, we will find out that we are generating a single CSS file and a single JavaScript file with every component code in it.

Bash console showing just two files built

But we resolved the first issue and styles are being imported in the built file:

/dist/react-vite-component-template.js
import './main.css'
import { jsx as l } from 'react/jsx-runtime'
// ...

As we are creating a library, we would like to allow users to import just the CSS from the used components. So we are going to turn every file imported into an entry point as Rollup recommends.

Also, we are changing entryFileNames and assetsFileNames in order to keep the folder structure in the build.

vite.config.ts
/// <reference types="vite/client" />
import path, { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { globSync } from 'glob'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({
plugins: [react(), libInjectCss()],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
input: Object.fromEntries(
globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => {
// This remove `src/` as well as the file extension from each
// file, so e.g. src/nested/foo.js becomes nested/foo
const entryName = path.relative(
'src',
file.slice(0, file.length - path.extname(file).length)
)
// This expands the relative paths to absolute paths, so e.g.
// src/nested/foo becomes /project/src/nested/foo.js
const entryUrl = fileURLToPath(new URL(file, import.meta.url))
return [entryName, entryUrl]
})
),
output: {
entryFileNames: '[name].js',
assetFileNames: 'assets/[name][extname]',
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
})

Building library types

For solving this, we are going to use vite-plugin-dts.

npm i -D vite-plugin-dts

Just add it in the Vite configuration plugins option:

vite.config.ts
/// <reference types="vite/client" />
import path, { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { globSync } from 'glob'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({
plugins: [
react(),
libInjectCss(),
dts({
tsconfigPath: 'tsconfig.app.json',
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
input: Object.fromEntries(
globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => {
const entryName = path.relative(
'src',
file.slice(0, file.length - path.extname(file).length)
)
const entryUrl = fileURLToPath(new URL(file, import.meta.url))
return [entryName, entryUrl]
})
),
output: {
entryFileNames: '[name].js',
assetFileNames: 'assets/[name][extname]',
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
})

Now, we are generating type declarations for our library.

ESLint

When we init a Vite project, ESLint v9 (flat config) is included by default, so for now we don’t have to install any dependency.

The eslint.config.js configuration file is like this. Modify it according to your preferences.

eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
)

Lastly, include these two scripts in the scripts section of your package.json file:

package.json
{
// ...
"scripts": {
// ...
"lint": "eslint . --ext ts,tsx",
"lint:fix": "eslint . --ext ts,tsx --fix"
}
}

To enable ESLint live validation in your IDE using the ESLint VSCode extension, create a settings.json file in the .vscode folder.

📂 my-react-lib
├─ 📂 .storybook
│ ├─ 📄 main.ts
│ └─ 📄 preview.ts
├─ 📂 .vscode
│ └─ 📄 settings.json
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📂 components
│ │ ├─ 📂 MyButton

Then, add the following content.

.vscode/settings.json
{
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript", // Enable .ts
"typescriptreact" // Enable .tsx
]
}

Prettier

Now let’s add Prettier.

This tool can conflict with ESLint rules, so we are going to add eslint-config-prettier to handle this.

npm i -D prettier eslint-config-prettier

Create a .prettierrc file with your preferred rules.

.prettierrc
{
"jsxSingleQuote": true,
"printWidth": 100,
"semi": false,
"singleAttributePerLine": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}

And the .prettierignore file:

.prettierignore
dist
node_modules
.github
.changeset

And extend the ESLint configuration to prevent conflicts with Prettier.

eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
}
)

As a final step, incorporate these two scripts into the scripts section of your package.json file

package.json
{
// ...
"scripts": {
// ...
"format": "prettier --check .",
"format:fix": "prettier --write ."
}
}

Stylelint

Let’s install Stylelint with the opinionated rules of stylelint-config-standard.

npm i -D stylelint stylelint-config-standard

Remember that stylelint-config-prettier is not anymore necessary in the latest versions of Stylelint (v15 or higher).

Create the .stylelintrc.mjs file, with imported typings:

.stylelintrc.mjs
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-standard'],
rules: {
// Regex for BEM classes: https://gist.github.com/Potherca/f2a65491e63338659c3a0d2b07eee382
'selector-class-pattern': '^.[a-z]([a-z0-9-]+)?(__([a-z0-9]+-?)+)?(--([a-z0-9]+-?)+){0,2}$',
},
}

And the .stylelintignore file:

.stylelintignore
dist

To finish up, insert these two scripts into the scripts field of your package.json:

package.json
{
// ...
"scripts": {
// ...
"stylelint": "stylelint **/*.css",
"stylelint:fix": "stylelint **/*.css --fix"
}
}

To enable Stylelint live validation in your IDE using the Stylelint VSCode extension, update the previously created settings.json file in the .vscode folder.

.vscode/settings.json
{
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript", // Enable .ts
"typescriptreact" // Enable .tsx
],
"stylelint.validate": ["scss", "css"]
}

Storybook

This tutorial is updated to Storybook 8.

For installing needed dependencies for Storybook we are using the Storybook CLI. Run the following command in the root of the project.

npx storybook@latest init

After the installation, we will update our ESLint configuration to ignore the .storybook folder and integrate eslint-plugin-storybook.

eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import storybook from 'eslint-plugin-storybook'
export default tseslint.config(
{ ignores: ['dist', '.storybook'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
{
files: ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)'],
extends: [...storybook.configs['flat/recommended']],
}
)

Also, two folders were created:

📂 my-react-lib
├─ 📂 .storybook
│ ├─ 📄 main.ts
│ └─ 📄 preview.ts
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📂 components
│ │ ├─ 📂 MyButton
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ ├─ 📂 MyCounter
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ └─ 📂 MyTitle
│ │ ├─ 📄 styles.module.css
│ │ └─ 📄 index.tsx
│ ├─ 📂 stories
│ │ ├─ ...
│ │ ├─ ...
│ │ └─ ...
│ └─ 📄 vite-env.d.ts
├─ 📄 index.html
📂 my-react-lib
├─ 📂 .storybook
│ ├─ 📄 main.ts
│ └─ 📄 preview.ts
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📂 components
│ │ ├─ 📂 MyButton
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ ├─ 📂 MyCounter
│ │ │ ├─ 📄 styles.module.css
│ │ │ └─ 📄 index.tsx
│ │ └─ 📂 MyTitle
│ │ ├─ 📄 styles.module.css
│ │ └─ 📄 index.tsx
│ └─ 📄 vite-env.d.ts
├─ 📄 index.html

The .storybook/main.ts file defines how a Storybook project works, such as the location of the stories, which addons are included, any special features activated, and other specific project settings.

.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@chromatic-com/storybook',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
}
export default config

The ./storybook/preview.ts file defines global decorators and parameters that will be applied across all stories.

.storybook/preview.ts
import type { Preview } from '@storybook/react'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
}
export default preview

Additionally, let’s modify the dts configuration within vite.config.ts to ensure that stories are excluded from the type declarations build process.

vite.config.ts
/// <reference types="vite/client" />
import path, { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { globSync } from 'glob'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({
plugins: [
react(),
libInjectCss(),
dts({
exclude: ['**/*.stories.tsx'],
tsconfigPath: 'tsconfig.app.json',
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
input: Object.fromEntries(
globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => {
// This remove `src/` as well as the file extension from each
// file, so e.g. src/nested/foo.js becomes nested/foo
const entryName = path.relative(
'src',
file.slice(0, file.length - path.extname(file).length)
)
// This expands the relative paths to absolute paths, so e.g.
// src/nested/foo becomes /project/src/nested/foo.js
const entryUrl = fileURLToPath(new URL(file, import.meta.url))
return [entryName, entryUrl]
})
),
output: {
entryFileNames: '[name].js',
assetFileNames: 'assets/[name][extname]',
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
})

And, finally, let’s integrate our component story. Create a file called MyButton.stories.tsx inside the component folder, and do the same with the rest of the components.

📂 my-react-lib
├─ 📂 .storybook
│ ├─ 📄 main.ts
│ └─ 📄 preview.ts
├─ 📂 src
│ ├─ 📂 assets
│ ├─ 📂 components
│ │ ├─ 📂 MyButton
│ │ │ ├─ 📄 styles.module.css
│ │ │ ├─ 📄 MyButton.stories.tsx
│ │ │ └─ 📄 index.tsx
│ │ ├─ 📂 MyCounter
│ │ │ ├─ 📄 styles.module.css
│ │ │ ├─ 📄 MyCounter.stories.tsx
│ │ │ └─ 📄 index.tsx
│ │ └─ 📂 MyTitle
│ │ ├─ 📄 styles.module.css
│ │ ├─ 📄 MyTitle.stories.tsx
│ │ └─ 📄 index.tsx
│ └─ 📄 vite-env.d.ts
├─ 📄 index.html

Let’s start by defining different views of our MyButton component. We’ll showcase its different variants, sizes, and functionalities. Additionally, we’ll incorporate a mock onClick event to demonstrate interactivity.

MyButton.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { fn } from '@storybook/test'
import { MyButton } from './'
const meta = {
title: 'Components/MyButton',
component: MyButton,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
label: 'Button',
onClick: fn(),
},
} satisfies Meta<typeof MyButton>
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
primary: true,
},
}
export const Secondary: Story = {
args: {
primary: false,
},
}
export const Large: Story = {
args: {
size: 'large',
},
}
export const Small: Story = {
args: {
size: 'small',
},
}

The Storybook CLI also added two scripts in our package.json.

package.json
{
// ...
"scripts": {
// ...
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
}
}

With this setup completed, we’re prepared to launch the Storybook development server or to build the Storybook distribution.

PostCSS

PostCSS allows us to process our CSS and comes integrated in Vite by default.

In this case, we are going to add Autoprefixer, a PostCSS plugin that add vendor prefixes to CSS rules.

Firstly, let’s install the necessary dependency.

npm i -D autoprefixer

Then, create a postcss.config.cjs file in the project root and add the following code for loading the plugin:

postcss.config.cjs
module.exports = {
plugins: [require('autoprefixer')],
}

If you wish to customize the Autoprefixer browser support, you have two options:

Personally, I prefer the autonomous file approach. So let’s add some configuration for demo purposes, feel free to customize as you want.

.browserslistrc
defaults
> 0.1%
not dead

With this settings in place, we are ready. To verify if Autoprefixer is working as expected, let’s prepare a quick example. Simply, insert the following line in styles.module.css:

styles.module.css
.button {
--bg-color: none;
background-color: var(--bg-color);
border-radius: 6px;
}
.button--primary {
--bg-color: crimson;
backdrop-filter: sepia(90%);
}
.button--small {
font-size: 1rem;
padding: 0.4rem 0.6rem;
}
.button--medium {
font-size: 1.2rem;
padding: 0.6rem 0.8rem;
}
.button--large {
font-size: 1.4rem;
padding: 0.8rem 1rem;
}

After executing the build script, navigate to the dist/assets/index2.css. If everything works correctly, you will find the needed vendor prefix for the backdrop-filter rule appended:

._button_31ovf_1{--bg-color: none;background-color:var(--bg-color);border-radius:6px}._button--primary_31ovf_15{--bg-color: crimson;-webkit-backdrop-filter:sepia(90%);backdrop-filter:sepia(90%)}._button--small_31ovf_27{font-size:1rem;padding:.4rem .6rem}._button--medium_31ovf_37{font-size:1.2rem;padding:.6rem .8rem}._button--large_31ovf_47{font-size:1.4rem;padding:.8rem 1rem}

Testing with Vitest

Yes, don’t run away!

Let’s set up the testing environment. For this purpose, we are going to use Vitest and React Testing Library.

First of all, we are going to install the necessary dependencies. You can view the official Vitest example here.

npm i -D vitest jsdom @testing-library/react

Then, add the test script to the package.json.

package.json
{
// ...
"scripts": {
// ...
"test": "vitest"
}
}

Now, let’s create a src/test/setup.ts file. To access testing methods, it’s necessary to import @testing-library/jest-dom in it.

src/test/setup.ts
import '@testing-library/jest-dom'
import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
// hooks are reset before each suite
afterEach(() => {
cleanup()
})

Now, we are going to create a test for our component. Begin crafting a file named MyButton.test.tsx within src/components/MyButton/.

This test will cover basic aspects, such as rendering, prop passing, disable property and onClick function.

MyButton.test.tsx
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, it, vi } from 'vitest'
import { MyButton } from '.'
describe('MyButton test:', () => {
afterEach(cleanup)
it('should render component', () => {
render(<MyButton label='Testing' />)
})
it('should render label', () => {
render(<MyButton label='Testing' />)
screen.getByText('Testing')
})
it('should be disabled', () => {
render(
<MyButton
label='Testing'
disabled
/>
)
expect(screen.getByRole('button')).toBeDisabled()
})
it('onClick triggers properly', async () => {
const mockFn = vi.fn()
render(
<MyButton
onClick={mockFn}
label='Testing'
/>
)
expect(mockFn).toHaveBeenCalledTimes(0)
fireEvent.click(screen.getByRole('button'))
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('disabled prevents action', async () => {
const mockFn = vi.fn()
render(
<MyButton
onClick={mockFn}
label='Testing'
disabled
/>
)
expect(mockFn).toHaveBeenCalledTimes(0)
fireEvent.click(screen.getByRole('button'))
expect(mockFn).toHaveBeenCalledTimes(0)
})
})

Now, let’s set up Vitest.

Following Vite recommendations, we are using the same file for Vite and Vitest configuration.

For this purpose, we need to extend the test field within vite.config.ts to correctly setup the test environment, and dts configuration to exclude our tests files from the type declarations from the build.

Also, as we previously did when configuring Vite, lets add a reference to Vitest types with the triple slash directive.

vite.config.ts
/// <reference types="vite/client" />
/// <reference types="vitest" />
import path, { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { globSync } from 'glob'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({
plugins: [
react(),
libInjectCss(),
dts({
exclude: ['**/*.stories.tsx', 'src/test', '**/*.test.tsx'],
tsconfigPath: 'tsconfig.app.json',
}),
,
],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
input: Object.fromEntries(
globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => {
const entryName = path.relative(
'src',
file.slice(0, file.length - path.extname(file).length)
)
const entryUrl = fileURLToPath(new URL(file, import.meta.url))
return [entryName, entryUrl]
})
),
output: {
entryFileNames: '[name].js',
assetFileNames: 'assets/[name][extname]',
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
// you might want to disable it, if you don't have tests that rely on CSS
// since parsing CSS is slow
css: true,
},
})

We are ready to run our tests with the npm run dev command:

Testing results with 5 passed and 0 failed

Adding Vitest UI and coverage

Now we are going to install two slightly less commonly used features, but they may be of interest to certain users.

The first one is @vitest/ui, a visual interface for viewing and interacting with tests.

The second one is @vitest/coverage, a code coverage tool for your code. Testing coverage evaluates how much of the code base is tested, aiding in identifying untested areas and potential errors.

Firstly, install both dependencies.

npm i -D @vitest/ui @vitest/coverage-v8

Next, add a new test:ui script in the package.json file with the --ui flag. Additionally, add another script called coverage.

package.json
{
// ...
"scripts": {
// ...
"test": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}

From now on, running the test:ui script will open a dev server with a test UI.

Now, add the coverage field in test config within vite.config.ts.

vite.config.ts
/// <reference types="vite/client" />
/// <reference types="vitest" />
import path, { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { globSync } from 'glob'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
export default defineConfig({
plugins: [
react(),
libInjectCss(),
dts({
exclude: ['**/*.stories.tsx'],
tsconfigPath: 'tsconfig.app.json',
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
input: Object.fromEntries(
globSync(['src/components/**/index.tsx', 'src/main.ts']).map((file) => {
const entryName = path.relative(
'src',
file.slice(0, file.length - path.extname(file).length)
)
const entryUrl = fileURLToPath(new URL(file, import.meta.url))
return [entryName, entryUrl]
})
),
output: {
entryFileNames: '[name].js',
assetFileNames: 'assets/[name][extname]',
globals: {
react: 'React',
'react-dom': 'React-dom',
'react/jsx-runtime': 'react/jsx-runtime',
},
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
coverage: {
include: ['src/components'],
exclude: ['**/*.stories.tsx'],
},
},
})

When we run the coverage script, a full coverage report will appear in the console, and a /coverage folder will be created in the project root with the results.

Publishing the package on npm

In this section, we will address how to correctly publish a package on npm, paying special attention to the scripts and necessary fields in the package.json file.

Defining entry points in the package.json

In our package.json we have to properly set some fields:

package.json
{
// Rest of package.json
"type": "module",
"files": ["dist"],
"exports": "./dist/main.js",
"module": "./dist/main.js",
"types": "./dist/main.d.ts"
}

And what about main field? main is used to point the cjs entry point and we are not supporting it.

Useful publish workflow scripts

We will enhance our workflow by incorporating a very useful script into our package.json file: the prepublishOnly script. This script executes before the package is prepared and packed, exclusively triggering during an npm publish operation.

Implementing this, it is guaranteed that our tests are successfully passing and that the build process is completed before we publish to the npm registry.

package.json
{
// ...
"scripts": {
// ...
"prepublishOnly": "vitest run && npm run build"
}
}

Now, if we run the npm publish command, the whole workflow will trigger if there is no error in tests and TypeScript check.

Console prompt after running "npm publish"

Conclusion

We have just created a repository from scratch for building and publishing a React component library with type declarations. We have used Vite with TypeScript and PostCSS. For live viewing, we opted for Storybook. And for testing, we chose Vitest and React Testing Library. Also, we have integrated several code quality tools as ESLint, Stylelint and Prettier.

Feel free to check and utilize this template in the GitHub repository.

Furthermore, if you have any comment, doubt or issue, do not hesitate to open an issue.

Related posts