Building Reusable Component Libraries
Creating Reusable Component Libraries
Building reusable component libraries is essential for maintaining consistency across applications and teams. In this lesson, we'll explore design patterns and tools for creating flexible, composable, and well-documented component libraries.
Design System Approach
A design system provides a unified set of components, patterns, and guidelines:
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.stories.tsx
│ │ ├── Button.test.tsx
│ │ ├── Button.module.css
│ │ └── index.ts
│ ├── Input/
│ ├── Card/
│ └── index.ts
├── tokens/
│ ├── colors.ts
│ ├── spacing.ts
│ ├── typography.ts
│ └── index.ts
├── utils/
│ └── classnames.ts
└── index.ts
// tokens/colors.ts
export const colors = {
primary: {
50: '#e3f2fd',
100: '#bbdefb',
500: '#2196f3',
700: '#1976d2',
900: '#0d47a1',
},
neutral: {
50: '#fafafa',
100: '#f5f5f5',
500: '#9e9e9e',
900: '#212121',
},
semantic: {
success: '#4caf50',
warning: '#ff9800',
error: '#f44336',
info: '#2196f3',
}
};
// tokens/spacing.ts
export const spacing = {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
xxl: '3rem',
};
// tokens/typography.ts
export const typography = {
fontFamily: {
sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
mono: ''Courier New', monospace',
},
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
}
};
Compound Components Pattern
Compound components allow parent and child components to work together implicitly:
import { createContext, useContext, useState } from 'react';
interface TabsContextValue {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs compound components must be used within Tabs');
}
return context;
}
// Parent Component
interface TabsProps {
defaultTab: string;
children: React.ReactNode;
}
function Tabs({ defaultTab, children }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// Tab List Component
function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
// Tab Component
interface TabProps {
value: string;
children: React.ReactNode;
}
function Tab({ value, children }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === value;
return (
<button
role="tab"
aria-selected={isActive}
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
// Tab Panels Component
function TabPanels({ children }: { children: React.ReactNode }) {
return <div className="tab-panels">{children}</div>;
}
// Tab Panel Component
interface TabPanelProps {
value: string;
children: React.ReactNode;
}
function TabPanel({ value, children }: TabPanelProps) {
const { activeTab } = useTabs();
if (activeTab !== value) return null;
return (
<div role="tabpanel" className="tab-panel">
{children}
</div>
);
}
// Compose everything
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
export default Tabs;
// Usage
function App() {
return (
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab value="profile">Profile</Tabs.Tab>
<Tabs.Tab value="settings">Settings</Tabs.Tab>
<Tabs.Tab value="notifications">Notifications</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel value="profile">
<h2>Profile Content</h2>
</Tabs.Panel>
<Tabs.Panel value="settings">
<h2>Settings Content</h2>
</Tabs.Panel>
<Tabs.Panel value="notifications">
<h2>Notifications Content</h2>
</Tabs.Panel>
</Tabs.Panels>
</Tabs>
);
}
Render Props Pattern
Render props allow components to share behavior without inheritance:
interface DataLoaderProps<T> {
url: string;
children: (data: {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}) => React.ReactNode;
}
function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return <>{children({ data, loading, error, refetch: fetchData })}</>;
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
function App() {
return (
<DataLoader<User[]> url="/api/users">
{({ data, loading, error, refetch }) => {
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}}
</DataLoader>
);
}
Higher-Order Components (HOCs)
HOCs are functions that take a component and return an enhanced component:
interface WithLoadingProps {
loading: boolean;
}
function withLoading<P extends object>(
Component: React.ComponentType<P>,
LoadingComponent: React.ComponentType = () => <p>Loading...</p>
) {
return function WithLoadingComponent(props: P & WithLoadingProps) {
const { loading, ...rest } = props;
if (loading) {
return <LoadingComponent />;
}
return <Component {...(rest as P)} />;
};
}
// Usage
interface UserListProps {
users: User[];
}
function UserList({ users }: UserListProps) {
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
const UserListWithLoading = withLoading(UserList);
function App() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
return <UserListWithLoading users={users} loading={loading} />;
}
interface AuthenticatedComponentProps {
user: User;
}
function withAuth<P extends AuthenticatedComponentProps>(
Component: React.ComponentType<P>
) {
return function WithAuthComponent(props: Omit<P, 'user'>) {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated || !user) {
return <Navigate to="/login" />;
}
return <Component {...(props as P)} user={user} />;
};
}
// Usage
function Dashboard({ user }: AuthenticatedComponentProps) {
return <h1>Welcome, {user.name}!</h1>;
}
const ProtectedDashboard = withAuth(Dashboard);
Headless Components
Headless components provide logic without styling, allowing full UI customization:
interface UseToggleOptions {
defaultOn?: boolean;
onToggle?: (isOn: boolean) => void;
}
interface UseToggleReturn {
isOn: boolean;
toggle: () => void;
setOn: () => void;
setOff: () => void;
getTogglerProps: (props?: React.HTMLAttributes<HTMLButtonElement>) => {
onClick: () => void;
'aria-pressed': boolean;
};
}
function useToggle({
defaultOn = false,
onToggle
}: UseToggleOptions = {}): UseToggleReturn {
const [isOn, setIsOn] = useState(defaultOn);
const toggle = useCallback(() => {
setIsOn(prev => {
const newValue = !prev;
onToggle?.(newValue);
return newValue;
});
}, [onToggle]);
const setOn = useCallback(() => {
setIsOn(true);
onToggle?.(true);
}, [onToggle]);
const setOff = useCallback(() => {
setIsOn(false);
onToggle?.(false);
}, [onToggle]);
const getTogglerProps = useCallback(
(props: React.HTMLAttributes<HTMLButtonElement> = {}) => ({
...props,
onClick: () => {
props.onClick?.({} as any);
toggle();
},
'aria-pressed': isOn,
}),
[isOn, toggle]
);
return { isOn, toggle, setOn, setOff, getTogglerProps };
}
// Usage - Complete UI control
function CustomToggle() {
const { isOn, getTogglerProps } = useToggle({
onToggle: (isOn) => console.log('Toggled:', isOn)
});
return (
<div>
<button
{...getTogglerProps()}
className={`toggle ${isOn ? 'on' : 'off'}`}
>
{isOn ? 'ON' : 'OFF'}
</button>
<p>The toggle is {isOn ? 'on' : 'off'}</p>
</div>
);
}
// Different UI implementation
function SwitchToggle() {
const { isOn, getTogglerProps } = useToggle();
return (
<label className="switch">
<input type="checkbox" checked={isOn} readOnly />
<span {...getTogglerProps()} className="slider" />
</label>
);
}
interface UseDropdownOptions {
onSelect?: (value: string) => void;
}
interface UseDropdownReturn {
isOpen: boolean;
selectedValue: string | null;
open: () => void;
close: () => void;
toggle: () => void;
select: (value: string) => void;
getMenuProps: () => React.HTMLAttributes<HTMLDivElement>;
getItemProps: (value: string) => React.HTMLAttributes<HTMLButtonElement>;
getTriggerProps: () => React.HTMLAttributes<HTMLButtonElement>;
}
function useDropdown({ onSelect }: UseDropdownOptions = {}): UseDropdownReturn {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
const select = useCallback((value: string) => {
setSelectedValue(value);
setIsOpen(false);
onSelect?.(value);
}, [onSelect]);
const getTriggerProps = useCallback(() => ({
onClick: toggle,
'aria-expanded': isOpen,
'aria-haspopup': true as const,
}), [isOpen, toggle]);
const getMenuProps = useCallback(() => ({
role: 'menu',
hidden: !isOpen,
}), [isOpen]);
const getItemProps = useCallback((value: string) => ({
role: 'menuitem',
onClick: () => select(value),
}), [select]);
return {
isOpen,
selectedValue,
open,
close,
toggle,
select,
getTriggerProps,
getMenuProps,
getItemProps,
};
}
// Usage
function CustomDropdown() {
const dropdown = useDropdown({
onSelect: (value) => console.log('Selected:', value)
});
return (
<div className="dropdown">
<button {...dropdown.getTriggerProps()}>
{dropdown.selectedValue || 'Select option'}
</button>
<div {...dropdown.getMenuProps()} className="dropdown-menu">
<button {...dropdown.getItemProps('option1')}>Option 1</button>
<button {...dropdown.getItemProps('option2')}>Option 2</button>
<button {...dropdown.getItemProps('option3')}>Option 3</button>
</div>
</div>
);
}
Documenting with Storybook
Storybook is the industry standard for component documentation:
# Initialize Storybook
npx storybook@latest init
# Run Storybook
npm run storybook
// 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'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
description: 'Button style variant',
},
size: {
control: 'radio',
options: ['small', 'medium', 'large'],
},
disabled: {
control: 'boolean',
},
},
};
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 Disabled: Story = {
args: {
disabled: true,
children: 'Disabled Button',
},
};
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
</div>
),
};
export const WithIcon: Story = {
args: {
children: (
<>
<span>📧</span>
Send Email
</>
),
},
};
Exercise 1: Build Compound Modal
Create a compound modal component with:
- Modal (parent), Modal.Header, Modal.Body, Modal.Footer
- Shared context for open/close state
- Accessibility features (focus trap, ESC to close)
- Animation support
- Portal rendering
Exercise 2: Headless Pagination
Build a headless pagination hook:
- Track current page, total pages, items per page
- Provide methods: nextPage, prevPage, goToPage
- Return prop getters for page buttons
- Handle edge cases (first/last page)
- Support custom UI implementations
Exercise 3: Design System Setup
Initialize a component library with design system:
- Define design tokens (colors, spacing, typography)
- Create 5 base components (Button, Input, Card, Badge, Avatar)
- Set up Storybook with documentation
- Add TypeScript types for all components
- Implement consistent API patterns