20 KiB
20 KiB
Component Library Architecture
Complete guide to building, organizing, and maintaining a scalable component library.
Component Architecture Principles
1. Atomic Design
Organize components hierarchically from smallest to largest.
Hierarchy:
Atoms → Molecules → Organisms → Templates → Pages
Example Structure:
components/
├── atoms/
│ ├── Button/
│ ├── Input/
│ ├── Icon/
│ └── Label/
├── molecules/
│ ├── FormField/
│ ├── SearchBar/
│ └── Card/
├── organisms/
│ ├── Header/
│ ├── LoginForm/
│ └── ProductCard/
├── templates/
│ ├── DashboardLayout/
│ └── AuthLayout/
└── pages/
├── HomePage/
└── ProfilePage/
2. Component Types
Presentational (Dumb) Components:
- Focus on how things look
- No state management
- Receive data via props
- Highly reusable
// Presentational Button
interface ButtonProps {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
children,
onClick,
}) => {
return (
<button
className={`btn btn--${variant} btn--${size}`}
onClick={onClick}
>
{children}
</button>
);
};
Container (Smart) Components:
- Focus on how things work
- Manage state
- Connect to data sources
- Use presentational components
// Container Component
export const UserProfileContainer: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser().then(data => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <LoadingSpinner />;
if (!user) return <ErrorMessage />;
return <UserProfile user={user} />;
};
3. Component Composition
Build complex components from simple ones.
// Atoms
const Avatar: React.FC<AvatarProps> = ({ src, alt }) => (
<img className="avatar" src={src} alt={alt} />
);
const Badge: React.FC<BadgeProps> = ({ children }) => (
<span className="badge">{children}</span>
);
// Molecule (composed of atoms)
const UserBadge: React.FC<UserBadgeProps> = ({ user }) => (
<div className="user-badge">
<Avatar src={user.avatar} alt={user.name} />
<span className="user-badge__name">{user.name}</span>
<Badge>{user.role}</Badge>
</div>
);
// Organism (composed of molecules)
const UserList: React.FC<UserListProps> = ({ users }) => (
<ul className="user-list">
{users.map(user => (
<li key={user.id}>
<UserBadge user={user} />
</li>
))}
</ul>
);
File Structure
Standard Component Structure
Button/
├── Button.tsx # Main component
├── Button.test.tsx # Tests
├── Button.stories.tsx # Storybook stories
├── Button.module.css # Styles (CSS Modules)
├── Button.types.ts # TypeScript types
└── index.ts # Barrel export
Example Files
Button/Button.tsx
import React from 'react';
import styles from './Button.module.css';
import { ButtonProps } from './Button.types';
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
children,
onClick,
...props
}) => {
const className = [
styles.button,
styles[`button--${variant}`],
styles[`button--${size}`],
disabled && styles['button--disabled'],
loading && styles['button--loading'],
].filter(Boolean).join(' ');
return (
<button
className={className}
disabled={disabled || loading}
onClick={onClick}
aria-busy={loading}
{...props}
>
{loading && <span className={styles.spinner} />}
{children}
</button>
);
};
Button/Button.types.ts
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style variant */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Disabled state */
disabled?: boolean;
/** Loading state */
loading?: boolean;
/** Button content */
children: React.ReactNode;
/** Click handler */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('applies variant classes', () => {
render(<Button variant="secondary">Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toHaveClass('button--secondary');
});
it('disables interaction when loading', () => {
const handleClick = jest.fn();
render(<Button loading onClick={handleClick}>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-busy', 'true');
});
});
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,
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
};
export const Loading: Story = {
args: {
loading: true,
children: 'Loading...',
},
};
export const Disabled: Story = {
args: {
disabled: true,
children: 'Disabled Button',
},
};
Button/index.ts
export { Button } from './Button';
export type { ButtonProps } from './Button.types';
Common Components
1. Button Component
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
icon?: React.ReactNode;
iconPosition?: 'left' | 'right';
children: React.ReactNode;
onClick?: () => void;
}
2. Input Component
interface InputProps {
type?: 'text' | 'email' | 'password' | 'number' | 'tel';
label?: string;
placeholder?: string;
value?: string;
error?: string;
helpText?: string;
required?: boolean;
disabled?: boolean;
onChange?: (value: string) => void;
}
export const Input: React.FC<InputProps> = ({
type = 'text',
label,
placeholder,
value,
error,
helpText,
required,
disabled,
onChange,
}) => {
const id = useId();
const errorId = `${id}-error`;
const helpId = `${id}-help`;
return (
<div className="input-wrapper">
{label && (
<label htmlFor={id} className="input-label">
{label}
{required && <span aria-label="required">*</span>}
</label>
)}
<input
id={id}
type={type}
className={`input ${error ? 'input--error' : ''}`}
placeholder={placeholder}
value={value}
disabled={disabled}
required={required}
aria-invalid={!!error}
aria-describedby={error ? errorId : helpText ? helpId : undefined}
onChange={(e) => onChange?.(e.target.value)}
/>
{error && (
<span id={errorId} className="input-error" role="alert">
{error}
</span>
)}
{helpText && !error && (
<span id={helpId} className="input-help">
{helpText}
</span>
)}
</div>
);
};
3. Card Component
interface CardProps {
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
clickable?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export const Card: React.FC<CardProps> = ({
variant = 'default',
padding = 'md',
clickable = false,
children,
onClick,
}) => {
const Component = clickable ? 'button' : 'div';
return (
<Component
className={`card card--${variant} card--padding-${padding}`}
onClick={onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
>
{children}
</Component>
);
};
4. Modal Component
interface ModalProps {
open: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'full';
children: React.ReactNode;
onClose: () => void;
}
export const Modal: React.FC<ModalProps> = ({
open,
title,
size = 'md',
children,
onClose,
}) => {
useEffect(() => {
if (open) {
// Trap focus in modal
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [open]);
if (!open) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className={`modal modal--${size}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{title && (
<div className="modal__header">
<h2 id="modal-title">{title}</h2>
<button
className="modal__close"
onClick={onClose}
aria-label="Close dialog"
>
×
</button>
</div>
)}
<div className="modal__body">{children}</div>
</div>
</div>,
document.body
);
};
5. Dropdown Component
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
}
export const Dropdown: React.FC<DropdownProps> = ({
trigger,
children,
align = 'left',
}) => {
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="dropdown" ref={dropdownRef}>
<button
className="dropdown__trigger"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-haspopup="true"
>
{trigger}
</button>
{open && (
<div className={`dropdown__menu dropdown__menu--${align}`} role="menu">
{children}
</div>
)}
</div>
);
};
Component Patterns
1. Compound Components
Allow components to work together while sharing implicit state.
// Context for shared state
const AccordionContext = createContext<{
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
} | null>(null);
// Parent component
export const Accordion: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
return (
<AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
};
// Child component
export const AccordionItem: React.FC<{
index: number;
title: string;
children: React.ReactNode;
}> = ({ index, title, children }) => {
const context = useContext(AccordionContext);
if (!context) throw new Error('AccordionItem must be used within Accordion');
const { activeIndex, setActiveIndex } = context;
const isOpen = activeIndex === index;
return (
<div className="accordion-item">
<button
className="accordion-item__header"
onClick={() => setActiveIndex(isOpen ? null : index)}
aria-expanded={isOpen}
>
{title}
</button>
{isOpen && <div className="accordion-item__content">{children}</div>}
</div>
);
};
// Usage
<Accordion>
<AccordionItem index={0} title="Section 1">Content 1</AccordionItem>
<AccordionItem index={1} title="Section 2">Content 2</AccordionItem>
</Accordion>
2. Render Props
Pass rendering logic as a prop.
interface DataFetcherProps<T> {
url: string;
children: (data: {
data: T | null;
loading: boolean;
error: Error | null;
}) => React.ReactNode;
}
export function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return <>{children({ data, loading, error })}</>;
}
// Usage
<DataFetcher<User> url="/api/user">
{({ data, loading, error }) => {
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!data) return null;
return <UserProfile user={data} />;
}}
</DataFetcher>
3. Custom Hooks Pattern
Extract reusable logic into custom hooks.
// useToggle hook
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// Usage in component
export const Modal: React.FC<ModalProps> = ({ children }) => {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle();
return (
<>
<button onClick={open}>Open Modal</button>
{isOpen && (
<div className="modal">
{children}
<button onClick={close}>Close</button>
</div>
)}
</>
);
};
Styling Strategies
1. CSS Modules
/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
}
.button--primary {
background: var(--color-primary);
color: var(--color-white);
}
.button--secondary {
background: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
import styles from './Button.module.css';
export const Button = ({ variant }) => (
<button className={`${styles.button} ${styles[`button--${variant}`]}`}>
Click me
</button>
);
2. CSS-in-JS (Styled Components)
import styled from 'styled-components';
const StyledButton = styled.button<{ variant: string }>`
display: inline-flex;
align-items: center;
padding: ${({ theme }) => theme.space[3]} ${({ theme }) => theme.space[4]};
background: ${({ theme, variant }) =>
variant === 'primary' ? theme.colors.primary : 'transparent'};
color: ${({ theme, variant }) =>
variant === 'primary' ? theme.colors.white : theme.colors.primary};
`;
export const Button = ({ variant, children }) => (
<StyledButton variant={variant}>{children}</StyledButton>
);
3. Tailwind CSS
import clsx from 'clsx';
export const Button: React.FC<ButtonProps> = ({ variant, size, children }) => {
return (
<button
className={clsx(
'inline-flex items-center rounded-md font-medium',
{
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
'bg-transparent text-blue-600 border border-blue-600': variant === 'secondary',
},
{
'px-3 py-2 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
}
)}
>
{children}
</button>
);
};
Documentation
Component Documentation Template
/**
* Button component for triggering actions and navigation.
*
* @example
* ```tsx
* <Button variant="primary" onClick={handleClick}>
* Click me
* </Button>
* ```
*
* @see {@link https://design-system.example.com/button | Design System Docs}
*/
export const Button: React.FC<ButtonProps> = ({ ... }) => {
// Implementation
};
Storybook Documentation
import type { Meta } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
docs: {
description: {
component: `
Button component for triggering actions.
## Usage
\`\`\`tsx
import { Button } from '@/components/Button';
<Button variant="primary">Click me</Button>
\`\`\`
## Accessibility
- Keyboard accessible
- Screen reader friendly
- WCAG 2.1 AA compliant
`,
},
},
},
};
Testing Strategies
Unit Tests
describe('Button', () => {
it('renders with correct variant', () => {
render(<Button variant="primary">Test</Button>);
expect(screen.getByRole('button')).toHaveClass('button--primary');
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Test</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
Accessibility Tests
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Button>Test</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Visual Regression Tests
// Using Chromatic or Percy
it('matches snapshot', () => {
const { container } = render(<Button variant="primary">Test</Button>);
expect(container).toMatchSnapshot();
});
Performance Optimization
Code Splitting
// Lazy load heavy components
const HeavyComponent = lazy(() => import('./HeavyComponent'));
export const App = () => (
<Suspense fallback={<LoadingSpinner />}>
<HeavyComponent />
</Suspense>
);
Memoization
// Memoize expensive components
export const ExpensiveComponent = memo(({ data }) => {
// Expensive rendering logic
return <div>{processData(data)}</div>;
});
// Memoize callbacks
const handleClick = useCallback(() => {
// Handle click
}, [dependencies]);
// Memoize values
const expensiveValue = useMemo(() => {
return computeExpensiveValue(data);
}, [data]);
Version Control
Semantic Versioning
MAJOR.MINOR.PATCH
1.0.0 → Initial release
1.1.0 → New feature (backwards compatible)
1.1.1 → Bug fix (backwards compatible)
2.0.0 → Breaking change
Changelog
# Changelog
## [2.0.0] - 2024-01-15
### Breaking Changes
- Removed `type` prop from Button (use `variant` instead)
### Added
- New `loading` state for Button
- Icon support in Button component
### Fixed
- Button focus indicator contrast ratio
## [1.1.0] - 2024-01-01
### Added
- New Input component
- Card component variants
Resources
"Good components are reusable, accessible, and well-documented."