Creating Your Own React Library
Build a reusable React library from scratch, including hooks, components, TypeScript types, build configuration, and publishing.
Topics Covered:
Prerequisites:
- Building Scalable React Applications
Overview
Creating reusable React libraries is a critical skill for Staff Engineers. Whether building a design system, shared component library, or utility hooks, understanding the entire library development lifecycle is essential. This comprehensive tutorial will walk you through creating a production-ready React library from scratch, including architecture decisions, build configuration, TypeScript setup, testing, documentation, and publishing to npm. You'll learn patterns used by popular libraries like Material-UI, Chakra UI, and React Hook Form.
Lesson 1: Planning Your Library Architecture
Before writing code, plan your library's architecture. This determines everything else. Step 1: Define Scope What will your library include? • Components only? • Hooks only? • Utilities? • All of the above? Step 2: Define Target Audience • Internal teams (private npm org) • Public open source • Design system for company Step 3: Choose Module Format • ESM (ES Modules) - Modern, tree-shakeable • CJS (CommonJS) - Node.js compatibility • UMD - Browser globals • Multiple formats for maximum compatibility Step 4: Dependencies Strategy • Peer dependencies for React (prevents duplicate React) • External dependencies vs bundled • Size considerations
// Example: Library architecture planning
// MyAwesomeUI Library Plan
// Scope: React components + hooks
// Target: Public npm package
// Formats: ESM + CJS
// React: Peer dependency
library-name/
├── src/ # Source code
│ ├── components/ # React components
│ ├── hooks/ # Custom hooks
│ ├── utils/ # Utility functions
│ ├── types/ # TypeScript types
│ └── index.ts # Main entry point
├── dist/ # Build output (generated)
├── tests/ # Test files
├── docs/ # Documentation
├── package.json # Package configuration
├── tsconfig.json # TypeScript config
├── rollup.config.js # Build configuration
└── README.md # DocumentationPlan your structure first. A clear structure makes everything easier - building, testing, and maintaining.
Lesson 2: Setting Up Project Structure
Create a professional library structure that scales. Step 1: Initialize Project ```bash mkdir my-react-library cd my-react-library npm init -y ``` Step 2: Create Folder Structure Organize code logically: • src/: Source code • dist/: Build output • tests/: Test files • docs/: Documentation • examples/: Usage examples Step 3: Setup Git Initialize repository and add .gitignore Step 4: Install Dependencies Development dependencies: • TypeScript • Build tool (Rollup/Vite/tsup) • Testing library • Linting tools
// Complete project structure
my-react-library/
├── .gitignore
├── .npmignore
├── package.json
├── tsconfig.json
├── tsconfig.build.json # Separate build config
├── rollup.config.js # or vite.config.ts, etc.
├── jest.config.js # Testing config
├── .eslintrc.js # Linting
├── README.md
├── LICENSE
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ └── index.ts
│ │ └── Input/
│ │ ├── Input.tsx
│ │ └── index.ts
│ ├── hooks/
│ │ ├── useToggle.ts
│ │ └── useLocalStorage.ts
│ ├── utils/
│ │ └── cn.ts # className utility
│ ├── types/
│ │ └── index.ts
│ └── index.ts # Main export
├── dist/ # Generated
│ ├── index.esm.js
│ ├── index.cjs.js
│ ├── index.d.ts
│ └── index.d.ts.map
├── tests/
│ └── setup.ts
├── docs/
│ └── getting-started.md
└── examples/
└── basic/
└── App.tsx
// .gitignore
node_modules/
dist/
coverage/
*.log
.DS_Store
// .npmignore (opposite of .gitignore)
src/
tests/
examples/
*.config.js
*.config.ts
tsconfig*.json
.gitignoreA well-organized structure makes your library professional and easy to navigate. Separate source, build output, tests, and documentation clearly.
Lesson 3: Package.json Configuration
Configure package.json for a modern, tree-shakeable library. Key Fields: • name: npm package name • version: Semantic versioning • main: CommonJS entry point • module: ESM entry point • types: TypeScript declarations • exports: Modern package exports • peerDependencies: React dependencies • files: What to include in package • sideEffects: Enable tree-shaking Modern Package Exports: The "exports" field is the modern way to define entry points. It enables: • Tree-shaking • Conditional exports • Multiple entry points • Subpath exports
// package.json for a React library
{
"name": "@my-org/my-react-library",
"version": "1.0.0",
"description": "A collection of React components and hooks",
"license": "MIT",
"author": "Your Name",
"repository": {
"type": "git",
"url": "https://github.com/my-org/my-react-library"
},
// Entry points
"main": "./dist/index.cjs.js", // CommonJS
"module": "./dist/index.esm.js", // ESM
"types": "./dist/index.d.ts", // TypeScript types
// Modern exports (preferred)
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.esm.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs.js"
}
},
"./styles": "./dist/styles.css",
"./package.json": "./package.json"
},
// Files to include in npm package
"files": [
"dist",
"README.md",
"LICENSE"
],
// Peer dependencies (not bundled)
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
// Peer dependency ranges
"peerDependenciesMeta": {
"react": {
"optional": false
},
"react-dom": {
"optional": false
}
},
// Enable tree-shaking
"sideEffects": false,
// Scripts
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src",
"type-check": "tsc --noEmit",
"prepare": "npm run build"
},
// Keywords for npm search
"keywords": [
"react",
"components",
"hooks",
"ui-library"
]
}Modern package.json configuration enables tree-shaking, supports multiple module formats, and follows npm best practices. The "exports" field is the modern standard.
Lesson 4: TypeScript Configuration for Libraries
Proper TypeScript configuration is crucial for library consumers. Key Configuration Options: • declaration: Generate .d.ts files • declarationMap: Source maps for declarations • outDir: Output directory • module: Module system (ESNext) • target: JavaScript target (ES2015+) • jsx: React JSX handling • strict: Enable strict mode • esModuleInterop: Enable ESM/CJS interop Separate Configs: • tsconfig.json: Development config • tsconfig.build.json: Build config (stricter)
// tsconfig.json (Development)
{
"compilerOptions": {
// Target and Module
"target": "ES2015",
"module": "ESNext",
"lib": ["ES2015", "DOM", "DOM.Iterable"],
// Module Resolution
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
// Type Checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Emit
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
// JSX
"jsx": "react-jsx",
// Paths
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
// tsconfig.build.json (Build - stricter)
{
"extends": "./tsconfig.json",
"compilerOptions": {
// Remove paths for build
"paths": {},
// Emit only declarations
"emitDeclarationOnly": false
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.stories.tsx"]
}
// Using in build script
// "build": "tsc -p tsconfig.build.json && rollup -c"Two TypeScript configs: one for development (with paths, includes tests) and one for building (stricter, excludes tests). Declaration files enable TypeScript support for consumers.
Lesson 5: Choosing and Configuring Build Tools
Multiple build tools can build React libraries. Choose based on your needs. Build Tool Options: 1. Rollup (Recommended for Libraries) • Optimized for libraries • Excellent tree-shaking • Multiple output formats • Plugin ecosystem 2. Vite • Fast development • Easy configuration • Good for modern setups 3. tsup • Zero-config TypeScript bundler • Uses esbuild (very fast) • Simple setup 4. Webpack • More complex • Better for apps than libraries We'll focus on Rollup as it's the most popular for libraries.
// rollup.config.js - Complete configuration
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import dts from 'rollup-plugin-dts';
const packageJson = require('./package.json');
export default [
// ESM and CJS builds
{
input: 'src/index.ts',
output: [
{
file: packageJson.module,
format: 'es',
sourcemap: true,
exports: 'named',
},
{
file: packageJson.main,
format: 'cjs',
sourcemap: true,
exports: 'named',
},
],
plugins: [
peerDepsExternal(), // Don't bundle peer deps
resolve({
browser: true,
}),
commonjs(),
typescript({
tsconfig: './tsconfig.build.json',
declaration: false, // Handled by dts plugin
}),
terser(), // Minify
],
external: ['react', 'react-dom'], // External dependencies
},
// TypeScript declarations
{
input: 'src/index.ts',
output: {
file: packageJson.types,
format: 'es',
},
plugins: [dts()],
external: [/.css$/], // Exclude CSS from declarations
},
];
// Install dependencies:
// npm install -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-terser rollup-plugin-peer-deps-external rollup-plugin-dts
// Alternative: Vite configuration (simpler)
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyReactLibrary',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'esm' : 'cjs'}.js`,
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
// Alternative: tsup (zero-config)
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
});Rollup is the most popular choice for libraries due to excellent tree-shaking. Vite is simpler but less flexible. tsup is fastest but less customizable. Choose based on your needs.
Lesson 6: Creating Library Entry Point
The entry point (index.ts) is your library's public API. Design it carefully. Principles: • Export only what consumers need • Use barrel exports • Group related exports • Document everything Export Patterns: • Named exports for components/hooks • Type exports for TypeScript • Re-export from sub-modules • Avoid default exports (tree-shaking issues)
// src/index.ts - Main entry point
// Components
export { Button } from './components/Button';
export type { ButtonProps } from './components/Button';
export { Input } from './components/Input';
export type { InputProps } from './components/Input';
// Hooks
export { useToggle } from './hooks/useToggle';
export { useLocalStorage } from './hooks/useLocalStorage';
// Utils
export { cn } from './utils/cn';
// Types
export type { Theme } from './types/theme';
// Version info (optional)
export const version = '1.0.0';
// Alternative: Barrel export pattern
// If you have many exports, use barrel files
// src/components/index.ts
export { Button } from './Button';
export { Input } from './Input';
// src/hooks/index.ts
export { useToggle } from './useToggle';
export { useLocalStorage } from './useLocalStorage';
// src/index.ts (then re-exports)
export * from './components';
export * from './hooks';
export * from './utils';
export * from './types';
// ❌ Avoid: Default exports
// export default { Button, Input }; // Bad for tree-shaking
// ✅ Good: Named exports
export { Button, Input }; // Tree-shakeable
// Example component structure:
// src/components/Button/Button.tsx
export interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'md',
children,
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
{...props}
>
{children}
</button>
);
}
// src/components/Button/index.ts
export { Button } from './Button';
export type { ButtonProps } from './Button';Clean entry points enable tree-shaking. Use named exports, barrel exports for organization, and export types separately. Avoid default exports for better optimization.
Lesson 7: Building Components for Libraries
Library components have different requirements than app components. Key Considerations: • Composability: Flexible and reusable • Props API: Clear, intuitive • Styling: CSS-in-JS, CSS modules, or utility classes • Accessibility: ARIA attributes • TypeScript: Full type safety • Forwarding refs: Use forwardRef • Polymorphism: Support different element types Best Practices: • Forward refs for DOM components • Use polymorphic patterns for flexibility • Provide sensible defaults • Document all props
// Library component best practices
// 1. Forward refs
import { forwardRef } from 'react';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
children: React.ReactNode;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
aria-busy={loading}
{...props}
>
{loading ? <Spinner /> : children}
</button>
);
}
);
Button.displayName = 'Button';
// 2. Polymorphic component
type BoxProps<C extends React.ElementType> = {
as?: C;
children: React.ReactNode;
} & React.ComponentPropsWithoutRef<C>;
export function Box<C extends React.ElementType = 'div'>({
as,
children,
...props
}: BoxProps<C>) {
const Component = as || 'div';
return <Component {...props}>{children}</Component>;
}
// Usage:
// <Box as="button" onClick={handleClick}>Click</Box>
// <Box as="section" id="content">Content</Box>
// 3. Compound components
const CardContext = createContext<{ variant?: string }>({});
function Card({ children, variant }: CardProps) {
return (
<CardContext.Provider value={{ variant }}>
<div className={`card card-${variant}`}>{children}</div>
</CardContext.Provider>
);
}
Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>;
};
Card.Body = function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>;
};
// Usage:
// <Card variant="outlined">
// <Card.Header>Title</Card.Header>
// <Card.Body>Content</Card.Body>
// </Card>
// 4. Controlled/Uncontrolled components
export interface InputProps {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}
export function Input({ value, defaultValue, onChange, ...props }: InputProps) {
const [internalValue, setInternalValue] = useState(defaultValue || '');
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internalValue;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (!isControlled) {
setInternalValue(newValue);
}
onChange?.(newValue);
};
return <input value={currentValue} onChange={handleChange} {...props} />;
}Library components need to be flexible, composable, and accessible. Forward refs for DOM access, use polymorphic patterns for flexibility, and support both controlled and uncontrolled modes.
Lesson 8: Testing Your Library
Testing ensures your library works correctly and prevents regressions. Testing Strategy: • Unit tests: Components and hooks • Integration tests: Component interactions • Snapshot tests: Prevent UI regressions • Visual tests: Storybook with Chromatic Testing Tools: • Jest: Test runner • React Testing Library: Component testing • @testing-library/react-hooks: Hook testing • Storybook: Component development and visual testing
// Testing setup
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.stories.{ts,tsx}',
'!src/**/*.d.ts',
],
};
// Component test example
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when loading', () => {
render(<Button loading>Click</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
// Hook test example
// src/hooks/useToggle.test.ts
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
describe('useToggle', () => {
it('toggles value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(true);
});
});Comprehensive testing prevents bugs and gives confidence. Test components, hooks, and integrations. Use React Testing Library for user-centric tests.
Lesson 9: Publishing to npm
Publishing your library makes it available to others. Step 1: Prepare for Publishing • Build the library • Test everything • Update version • Write/update README • Add LICENSE Step 2: Create npm Account • Sign up at npmjs.com • Verify email Step 3: Login ```bash npm login ``` Step 4: Publish ```bash npm publish # or for scoped packages: npm publish --access public ``` Versioning: • Use semantic versioning (semver) • Major.Minor.Patch (1.0.0) • Use npm version command Publishing Checklist: • [ ] Library builds successfully • [ ] All tests pass • [ ] README is complete • [ ] LICENSE is included • [ ] Version is updated • [ ] package.json is correct
// Publishing workflow
// 1. Build your library
npm run build
// 2. Run tests
npm test
// 3. Update version (semantic versioning)
npm version patch # 1.0.0 -> 1.0.1 (bug fixes)
npm version minor # 1.0.0 -> 1.1.0 (new features)
npm version major # 1.0.0 -> 2.0.0 (breaking changes)
// Or manually edit package.json
{
"version": "1.0.0"
}
// 4. Login to npm
npm login
// 5. Publish
npm publish
// For scoped packages (@my-org/my-lib):
npm publish --access public
// Publishing with tags
npm publish --tag beta # Publish as beta
npm publish --tag latest # Publish as latest (default)
// Using in another project
npm install @my-org/my-react-library
// Or with specific version
npm install @my-org/my-react-library@1.0.0
// Publishing checklist script
// package.json
{
"scripts": {
"prepublishOnly": "npm run build && npm test",
"version": "npm run build"
}
}
// This ensures:
// - Build runs before publish
// - Tests pass before publish
// - Build runs on version bumpFollow the publishing workflow: build, test, version, publish. Use semantic versioning. The prepublishOnly script ensures quality before publishing.
Lesson 10: Documentation and Developer Experience
Great documentation makes your library successful. Documentation Essentials: • README.md: Getting started, examples • API documentation: All props and methods • Examples: Code samples • Storybook: Interactive component playground • Migration guides: For breaking changes Developer Experience: • Clear prop names • TypeScript types for autocomplete • Error messages • Examples in README • Storybook for visual testing
// README.md template
# My React Library
> A collection of React components and hooks
## Installation
```bash
npm install @my-org/my-react-library
```
## Usage
```tsx
import { Button, useToggle } from '@my-org/my-react-library';
function App() {
const [isOpen, toggle] = useToggle(false);
return (
<Button onClick={toggle}>
{isOpen ? 'Close' : 'Open'}
</Button>
);
}
```
## Components
### Button
A versatile button component.
**Props:**
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| variant | 'primary' | 'secondary' | 'primary' | Button style |
| size | 'sm' | 'md' | 'lg' | 'md' | Button size |
| loading | boolean | false | Show loading state |
| children | ReactNode | - | Button content |
**Example:**
```tsx
<Button variant="primary" size="lg" loading>
Submit
</Button>
```
## Hooks
### useToggle
Toggle a boolean value.
```tsx
const [value, toggle] = useToggle(false);
```
## Storybook Setup
// .storybook/main.js
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-essentials'],
};
// Component story
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Button',
},
};Comprehensive documentation is crucial. Include installation, usage examples, API docs, and Storybook for visual testing. Good docs = successful library.
Conclusion
Building libraries requires deep understanding of the ecosystem. Focus on DX (Developer Experience) and clear APIs. Start simple, iterate based on feedback, and maintain backwards compatibility. Remember: great libraries are used because they solve real problems and are easy to use.