React Component Library Patterns: Reusable Typed Components
Building a React component library requires organizing components, managing exports, documenting APIs, and maintaining backwards compatibility. Using TypeScript, you can publish a library that consumers import safely with full IDE autocompletion and type inference. This article covers patterns for structuring component libraries, creating reusable type definitions, and ensuring a smooth developer experience for library users.
Organizing Component Files
A scalable component library separates logic, styles, tests, and exports. A typical folder structure:
src/
components/
Button/
Button.tsx # Component definition
Button.types.ts # Type definitions
Button.test.tsx # Tests
Button.stories.tsx # Storybook
index.ts # Public export
Card/
Card.tsx
Card.types.ts
Card.test.tsx
Card.stories.tsx
index.ts
Layout/
...
hooks/
useTheme/
useTheme.ts
index.ts
styles/
theme.ts
variables.css
index.ts # Main library export (barrel)
Each component gets its own folder with related files, making it easy to find and update individual components.
Extracting Type Definitions to Separate Files
Store component types in dedicated .types.ts files. This lets users import types independently of components:
// Button.types.ts
export interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
onClick?: () => void;
fullWidth?: boolean;
}
export interface ButtonRef {
focus: () => void;
blur: () => void;
}
Consumers can import types for testing or forwarding:
// Importing types
import type { ButtonProps } from '@mylib/react';
import { Button } from '@mylib/react';
// Useful for mocking or extending
const mockButtonProps: ButtonProps = {
label: 'Test',
variant: 'primary',
};
// Forwarding refs
const CustomButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => <Button {...props} />
);
Creating Barrel Exports
A barrel export file (index.ts) re-exports public components and types, simplifying imports for consumers:
// src/components/index.ts
export { Button } from './Button';
export type { ButtonProps, ButtonRef } from './Button/Button.types';
export { Card } from './Card';
export type { CardProps } from './Card/Card.types';
export { Layout } from './Layout';
export type { LayoutProps } from './Layout/Layout.types';
// src/hooks/index.ts
export { useTheme } from './useTheme';
export type { ThemeContext } from './useTheme';
// src/index.ts (main library export)
export * from './components';
export * from './hooks';
export { theme } from './styles/theme';
Consumers can now import from the top level:
// Instead of:
import Button from '@mylib/react/components/Button';
import useTheme from '@mylib/react/hooks/useTheme';
// They can use:
import { Button, useTheme } from '@mylib/react';
Managing Component Defaults and Variants
Create a centralized configuration object for component defaults:
// src/styles/theme.ts
export const theme = {
colors: {
primary: '#2563eb',
secondary: '#64748b',
danger: '#ef4444',
},
sizes: {
small: '8px',
medium: '12px',
large: '16px',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
} as const;
export type Theme = typeof theme;
// src/components/Button/Button.tsx
import { theme } from '../../styles/theme';
interface ButtonProps {
label: string;
size?: keyof typeof theme.sizes;
}
const Button: React.FC<ButtonProps> = ({ label, size = 'medium' }) => (
<button style={{ padding: theme.sizes[size] }}>
{label}
</button>
);
This centralizes styling and prevents hardcoded values scattered across components.
Versioning and Backwards Compatibility
Mark breaking changes in APIs and version them. Maintain deprecated props with warnings:
// Button.types.ts (version 2)
interface ButtonPropsV2 {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
onClick?: () => void;
}
interface ButtonPropsV1 {
label: string; // Deprecated in v2, use children instead
onClick?: () => void;
}
// Union to support both versions
export type ButtonProps = ButtonPropsV2 | (ButtonPropsV1 & { label: string; children?: never });
// Button.tsx
const Button: React.FC<ButtonProps> = (props) => {
// Handle both old (label) and new (children) APIs
const children = 'children' in props ? props.children : props.label;
if ('label' in props) {
console.warn('[Button] The label prop is deprecated. Use children instead.');
}
return <button>{children}</button>;
};
This approach lets old code keep working while warning users to update.
Documenting Props with JSDoc
Use JSDoc comments for IDE autocompletion and generated documentation:
// Button.types.ts
/**
* Props for the Button component.
*
* @example
* <Button variant="primary" onClick={() => console.log('clicked')}>
* Click Me
* </Button>
*/
export interface ButtonProps {
/**
* The text or content displayed inside the button.
*/
children: React.ReactNode;
/**
* The visual style of the button.
* @default 'primary'
*/
variant?: 'primary' | 'secondary' | 'ghost';
/**
* The size of the button.
* @default 'medium'
*/
size?: 'small' | 'medium' | 'large';
/**
* Disables the button and prevents interaction.
* @default false
*/
disabled?: boolean;
/**
* Callback fired when the button is clicked.
*/
onClick?: () => void;
}
IDEs display these comments on hover, and documentation generators extract them.
Exporting Hooks and Utilities
A component library often includes custom hooks and utilities:
// src/hooks/useTheme.ts
export interface ThemeContext {
isDark: boolean;
primaryColor: string;
toggle: () => void;
}
const ThemeContext = React.createContext<ThemeContext | null>(null);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isDark, setIsDark] = React.useState(false);
const value: ThemeContext = {
isDark,
primaryColor: isDark ? '#222' : '#fff',
toggle: () => setIsDark(!isDark),
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContext => {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
// src/index.ts (export for library users)
export { ThemeProvider, useTheme };
export type { ThemeContext };
Consumers can wrap their app with the provider and use the hook:
import { ThemeProvider, useTheme } from '@mylib/react';
const App = () => (
<ThemeProvider>
<MyComponent />
</ThemeProvider>
);
const MyComponent = () => {
const theme = useTheme();
return <div style={{ color: theme.primaryColor }}>Hello</div>;
};
Publishing Type Declarations
When publishing to npm, ensure TypeScript definitions are included:
// package.json
{
"name": "@mylib/react",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
In tsconfig.json, enable declaration generation:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"lib": ["es2020", "dom"],
"target": "es2020"
}
}
TypeScript automatically generates .d.ts files during the build, which npm publishes alongside JavaScript.
Comparison: Library vs. App Components
| Aspect | Library | App |
|---|---|---|
| Props | Strictly typed, documented | Can be more flexible |
| Exports | Barrel exports, public API | Internal only, less formality |
| Types | Separate .types.ts files | Inline or local types/ folder |
| Versioning | Semver, backwards compatibility | Internal iteration allowed |
| Documentation | JSDoc, generated docs | README or wiki |
| Testing | Comprehensive unit/integration tests | Focused on features |
Key Takeaways
- Organize component libraries with one folder per component, separating logic, types, styles, and tests.
- Use barrel exports (
index.ts) to simplify imports for library users. - Store type definitions in separate
.types.tsfiles so users can import types independently. - Document props with JSDoc comments for IDE autocompletion and generated documentation.
- Maintain backwards compatibility by supporting deprecated props with runtime warnings.
- Publish TypeScript declarations (
.d.tsfiles) alongside JavaScript so consumers get full type safety. - Export custom hooks and utilities with clear type definitions for context and values.
Frequently Asked Questions
Should I ship source TypeScript files or compiled JavaScript with type declarations?
Compile to JavaScript and publish .d.ts declaration files alongside. Source TypeScript files complicate bundling and create version mismatches. Declarations are generated during the build.
How do I handle breaking changes in a component library?
Increment the major version (semver) and document migration steps in a changelog. For a period, you can support both old and new APIs with deprecation warnings, allowing users time to migrate.
Can I have two APIs for the same component (e.g., label vs. children)?
Yes, use a union type and handle both in the component. Log a deprecation warning when the old API is used. Eventually, remove the old API in a major version.
Should I export all types or only component props?
Export component prop types and any types users need to extend or implement (like hook return types). Keep internal types (like local state) private.
How do I prevent users from importing internal/private components?
Organize your barrel exports to export only the public API. Don't export internal files directly. Document which exports are stable and which are experimental.