Staff Engineer180 min read

Creating Your Own React Library

Build a reusable React library from scratch, including hooks, components, TypeScript types, build configuration, and publishing.

Topics Covered:

Library ArchitectureBuild ToolsTypeScript SetupPublishingDocumentation

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

Code Example:
// 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             # Documentation

Plan 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

Code Example:
// 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
.gitignore

A 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

Code Example:
// 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)

Code Example:
// 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.

Code Example:
// 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)

Code Example:
// 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

Code Example:
// 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

Code Example:
// 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

Code Example:
// 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 bump

Follow 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

Code Example:
// 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.