React.js Fundamentals

Building Reusable Component Libraries

18 min Lesson 29 of 40

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:

Design System Principles: Consistency, reusability, accessibility, flexibility, and clear documentation are the foundation of successful component libraries.
Component Library Structure:
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
Design Tokens:
// 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:

Tabs Component Example:
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>
  );
}
Compound Component Benefits: This pattern provides a flexible, declarative API while maintaining internal state sharing between components. It's perfect for complex UI widgets like tabs, accordions, and menus.

Render Props Pattern

Render props allow components to share behavior without inheritance:

DataLoader with Render Props:
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:

withLoading HOC:
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} />;
}
withAuth HOC:
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);
HOC Limitations: HOCs can lead to "wrapper hell" and make debugging harder. Prefer hooks for cross-cutting concerns when possible. Use HOCs when you need to wrap components at build time.

Headless Components

Headless components provide logic without styling, allowing full UI customization:

Headless Toggle Component:
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>
  );
}
Headless Dropdown Component:
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>
  );
}
Headless UI Benefits: Separating logic from presentation allows maximum flexibility. Users can implement any design while leveraging robust behavior and accessibility features.

Documenting with Storybook

Storybook is the industry standard for component documentation:

Installing Storybook:
# Initialize Storybook
npx storybook@latest init

# Run Storybook
npm run storybook
Button Story Example:
// 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
      </>
    ),
  },
};
Storybook Benefits: Interactive component playground, automatic documentation, visual regression testing, accessibility testing, and shareable component showcase for designers and developers.

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