javascriptroom guide

Building Reusable Components with TypeScript

Reusable components are self-contained, modular UI elements designed to be used across multiple parts of an application (or even across projects). Think of buttons, form inputs, modals, or navigation bars—elements that appear repeatedly in UIs. **Key Traits of Reusable Components**: - **Isolation**: They encapsulate their own logic, styles, and behavior. - **Customization**: They accept props to adapt to different use cases (e.g., a `Button` with variants like "primary" or "secondary"). - **Consistency**: They enforce design standards (colors, spacing, typography) across the app. Without reusability, teams often duplicate code, leading to bugs, inconsistent UIs, and slower updates. TypeScript amplifies these benefits by adding type checks, making components easier to debug and use correctly.

In modern frontend development, the ability to create reusable components is a cornerstone of maintainable, scalable, and efficient codebases. Reusable components reduce redundancy, improve consistency, and speed up development by allowing teams to leverage pre-built UI elements across projects. When combined with TypeScript—a typed superset of JavaScript—reusable components become even more powerful, offering type safety, better tooling, and self-documenting code.

This blog will guide you through the process of building robust, reusable components with TypeScript. We’ll cover core principles, step-by-step implementation, advanced patterns, testing, and best practices to ensure your components are flexible, reliable, and easy to use.

Table of Contents

  1. Introduction to Reusable Components
  2. Why TypeScript for Reusable Components?
  3. Core Principles of Reusable Components
  4. Step-by-Step: Building a Reusable Component
  5. Advanced Patterns for Flexibility
  6. Testing Reusable Components
  7. Best Practices
  8. Conclusion
  9. References

Why TypeScript for Reusable Components?

TypeScript adds static typing to JavaScript, which is especially valuable for reusable components. Here’s why it matters:

1. Type Safety

TypeScript ensures props, state, and function parameters adhere to defined types. This catches errors during development (e.g., passing a string to a prop that expects a number) instead of at runtime.

2. Self-Documentation

Interfaces and type definitions act as living documentation. Developers using your component get autocompletion in IDEs (VS Code, WebStorm) and clear hints about required props.

3. Refactoring Confidence

Changing a component’s API (e.g., renaming a prop) is safer with TypeScript—you’ll immediately see all places in the codebase that need updates.

4. Better Collaboration

Type definitions make it easier for teams to understand how to use a component, reducing onboarding time.

Core Principles of Reusable Components

To build truly reusable components, follow these principles:

1. Single Responsibility

A component should do one thing and do it well. For example, a Button component should handle clicks and styling, but not complex form logic (that’s for a Form component).

2. Prop-Driven Customization

Expose props to let consumers customize behavior and appearance (e.g., variant, size, disabled). Avoid hardcoding values.

3. Composition Over Inheritance

Build complex components by combining smaller ones (e.g., a Card component composed of CardHeader, CardBody, and CardFooter).

4. Accessibility (a11y)

Ensure components work for all users: add ARIA roles, keyboard navigation, and screen-reader support.

5. Testability

Design components to be easy to test by keeping logic pure and avoiding tight coupling to external systems.

Step-by-Step: Building a Reusable Component

Let’s implement a Button component—one of the most common reusable UI elements. We’ll use React + TypeScript, but the principles apply to other frameworks (Vue, Angular).

Step 1: Set Up the Project

If you’re starting from scratch, create a React + TypeScript project:

npx create-react-app my-components --template typescript  
cd my-components  

Step 2: Define Props with TypeScript Interfaces

First, define the component’s props using a TypeScript interface. This ensures type safety and documents expected inputs.

Create a components/Button.tsx file:

// Define props for the Button component  
interface ButtonProps {  
  // Label displayed on the button  
  label: string;  

  // Optional variant for styling (primary, secondary, danger)  
  variant?: 'primary' | 'secondary' | 'danger';  

  // Optional size (sm, md, lg)  
  size?: 'sm' | 'md' | 'lg';  

  // Optional disabled state  
  disabled?: boolean;  

  // onClick handler (required for interactive buttons)  
  onClick: () => void;  
}  

Step 3: Implement the Component Logic

Use the props to render the button and apply dynamic styles. We’ll use CSS modules for scoping styles, but you could also use styled-components or Tailwind.

First, create Button.module.css:

.button {  
  padding: 0.5rem 1rem;  
  border: none;  
  border-radius: 4px;  
  cursor: pointer;  
  font-weight: 500;  
}  

/* Variants */  
.primary {  
  background-color: #007bff;  
  color: white;  
}  

.secondary {  
  background-color: #6c757d;  
  color: white;  
}  

.danger {  
  background-color: #dc3545;  
  color: white;  
}  

/* Sizes */  
.sm {  
  padding: 0.25rem 0.5rem;  
  font-size: 0.875rem;  
}  

.md {  
  padding: 0.5rem 1rem;  
  font-size: 1rem;  
}  

.lg {  
  padding: 0.75rem 1.5rem;  
  font-size: 1.25rem;  
}  

/* Disabled state */  
.disabled {  
  opacity: 0.6;  
  cursor: not-allowed;  
}  

Now, implement the Button component:

import React from 'react';  
import styles from './Button.module.css';  
import { ButtonProps } from './types'; // We’ll define this in a types file later  

const Button: React.FC<ButtonProps> = ({  
  label,  
  variant = 'primary', // Default variant  
  size = 'md', // Default size  
  disabled = false,  
  onClick,  
}) => {  
  // Combine base styles with variant/size classes  
  const buttonClasses = `${styles.button} ${styles[variant]} ${styles[size]} ${disabled ? styles.disabled : ''}`;  

  return (  
    <button  
      className={buttonClasses}  
      disabled={disabled}  
      onClick={onClick}  
      aria-disabled={disabled} // Accessibility: Screen readers recognize disabled state  
    >  
      {label}  
    </button>  
  );  
};  

export default Button;  

For larger projects, extract types into a dedicated file (e.g., components/types.ts) to keep code organized:

// components/types.ts  
export interface ButtonProps {  
  label: string;  
  variant?: 'primary' | 'secondary' | 'danger';  
  size?: 'sm' | 'md' | 'lg';  
  disabled?: boolean;  
  onClick: () => void;  
}  

Step 5: Use the Component

Now, import and use the Button in your app:

// App.tsx  
import Button from './components/Button';  

function App() {  
  return (  
    <div style={{ padding: '2rem' }}>  
      <Button label="Primary Button" onClick={() => alert('Clicked!')} />  
      <Button label="Secondary Button" variant="secondary" size="sm" onClick={() => {}} />  
      <Button label="Danger Button" variant="danger" size="lg" disabled onClick={() => {}} />  
    </div>  
  );  
}  

export default App;  

TypeScript will enforce that:

  • label and onClick are provided (they’re required in ButtonProps).
  • variant and size use only allowed values (no typos like “primmary”).

Advanced Patterns for Flexibility

To make components even more reusable, use these advanced TypeScript patterns:

1. Generic Components

Generics let components work with multiple data types. For example, a List component that renders items of any type:

// components/List.tsx  
import React from 'react';  

// Generic interface: T is a placeholder for the item type  
interface ListProps<T> {  
  items: T[];  
  renderItem: (item: T) => React.ReactNode; // Function to render each item  
}  

const List = <T,>({ items, renderItem }: ListProps<T>): React.ReactElement => {  
  return (  
    <ul>  
      {items.map((item, index) => (  
        <li key={index}>{renderItem(item)}</li>  
      ))}  
    </ul>  
  );  
};  

export default List;  

Usage:

// Render a list of strings  
<List  
  items={['Apple', 'Banana', 'Cherry']}  
  renderItem={(fruit) => <span>{fruit}</span>}  
/>  

// Render a list of objects  
interface User { id: number; name: string }  
<List  
  items={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}  
  renderItem={(user) => <div>User: {user.name}</div>}  
/>  

2. Compound Components

Compound components let users compose related components (e.g., Tabs with Tabs.List, Tabs.Tab, Tabs.Content). They share state implicitly via React Context.

Example: A Tabs component:

// components/Tabs.tsx  
import React, { createContext, useContext, useState, ReactNode } from 'react';  

// Define context to share active tab state  
type TabsContextType = {  
  activeTab: string;  
  setActiveTab: (tabId: string) => void;  
};  
const TabsContext = createContext<TabsContextType | undefined>(undefined);  

// Parent Tabs component  
interface TabsProps {  
  children: ReactNode;  
  defaultActiveTab?: string;  
}  
export const Tabs: React.FC<TabsProps> = ({ children, defaultActiveTab = '' }) => {  
  const [activeTab, setActiveTab] = useState(defaultActiveTab);  

  return (  
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>  
      <div className="tabs-container">{children}</div>  
    </TabsContext.Provider>  
  );  
};  

// Tabs.List: Renders tab headers  
export const TabsList: React.FC<{ children: ReactNode }> = ({ children }) => {  
  return <div className="tabs-list">{children}</div>;  
};  

// Tabs.Tab: Individual tab button  
interface TabsTabProps {  
  id: string;  
  children: ReactNode;  
}  
export const TabsTab: React.FC<TabsTabProps> = ({ id, children }) => {  
  const context = useContext(TabsContext);  
  if (!context) throw new Error('TabsTab must be used within Tabs');  

  const { activeTab, setActiveTab } = context;  
  const isActive = activeTab === id;  

  return (  
    <button  
      className={`tab ${isActive ? 'active' : ''}`}  
      onClick={() => setActiveTab(id)}  
    >  
      {children}  
    </button>  
  );  
};  

// Tabs.Content: Content for a tab  
interface TabsContentProps {  
  tabId: string;  
  children: ReactNode;  
}  
export const TabsContent: React.FC<TabsContentProps> = ({ tabId, children }) => {  
  const context = useContext(TabsContext);  
  if (!context) throw new Error('TabsContent must be used within Tabs');  

  const { activeTab } = context;  
  if (activeTab !== tabId) return null;  

  return <div className="tab-content">{children}</div>;  
};  

Usage:

<Tabs defaultActiveTab="tab1">  
  <TabsList>  
    <TabsTab id="tab1">Tab 1</TabsTab>  
    <TabsTab id="tab2">Tab 2</TabsTab>  
  </TabsList>  
  <TabsContent tabId="tab1">Content for Tab 1</TabsContent>  
  <TabsContent tabId="tab2">Content for Tab 2</TabsContent>  
</Tabs>  

3. Render Props

Render props let components share logic by accepting a function as a prop. For example, a MouseTracker component that exposes mouse coordinates:

interface MouseTrackerProps {  
  render: (x: number, y: number) => React.ReactNode;  
}  

const MouseTracker: React.FC<MouseTrackerProps> = ({ render }) => {  
  const [position, setPosition] = useState({ x: 0, y: 0 });  

  useEffect(() => {  
    const handleMouseMove = (e: MouseEvent) => setPosition({ x: e.clientX, y: e.clientY });  
    window.addEventListener('mousemove', handleMouseMove);  
    return () => window.removeEventListener('mousemove', handleMouseMove);  
  }, []);  

  return <>{render(position.x, position.y)}</>;  
};  

// Usage  
<MouseTracker render={(x, y) => <div>Mouse position: ({x}, {y})</div>} />  

Testing Reusable Components

Testing ensures your components work as expected across use cases. Use Jest (test runner) and React Testing Library (component testing) for TypeScript projects.

Example: Testing the Button Component

Create Button.test.tsx:

import React from 'react';  
import { render, screen, fireEvent } from '@testing-library/react';  
import Button from './Button';  

describe('Button', () => {  
  test('renders label correctly', () => {  
    render(<Button label="Test Button" onClick={() => {}} />);  
    expect(screen.getByText('Test Button')).toBeInTheDocument();  
  });  

  test('calls onClick when clicked', () => {  
    const mockOnClick = jest.fn();  
    render(<Button label="Click Me" onClick={mockOnClick} />);  

    fireEvent.click(screen.getByText('Click Me'));  
    expect(mockOnClick).toHaveBeenCalledTimes(1);  
  });  

  test('applies disabled state', () => {  
    render(<Button label="Disabled" onClick={() => {}} disabled />);  
    const button = screen.getByText('Disabled');  
    expect(button).toBeDisabled();  
  });  
});  

Best Practices

To maximize reusability and maintainability:

1. Keep Components Focused

Avoid “god components” that handle too much logic. Split complex UIs into smaller, reusable parts (e.g., ModalModalHeader, ModalBody, ModalFooter).

2. Use TypeScript Strictly

Enable strict: true in tsconfig.json to enforce strict type-checking (no any types, strict null checks).

3. Document Props and Usage

Add JSDoc comments to props for IDE tooltips:

/**  
 * A reusable button component with variants and sizes.  
 * @param {string} label - Text displayed on the button.  
 * @param {'primary' | 'secondary' | 'danger'} [variant='primary'] - Button style variant.  
 * @param {() => void} onClick - Callback when the button is clicked.  
 */  
const Button: React.FC<ButtonProps> = ({ label, variant, onClick }) => { ... };  

4. Prioritize Accessibility

  • Use semantic HTML (<button>, not <div onClick>).
  • Add ARIA attributes (e.g., aria-label, aria-expanded).
  • Support keyboard navigation (e.g., Tab to focus, Enter/Space to click buttons).

5. Optimize Performance

Use React.memo for components that re-render frequently with the same props:

const Button = React.memo<ButtonProps>(({ label, onClick }) => { ... });  

Conclusion

Building reusable components with TypeScript transforms how you develop UIs. By combining TypeScript’s type safety with principles like single responsibility and composition, you create components that are flexible, maintainable, and easy to debug.

Whether you’re building a design system for a large enterprise or a small app, investing in reusable components with TypeScript pays off in faster development, fewer bugs, and a more consistent user experience.

References