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
- Introduction to Reusable Components
- Why TypeScript for Reusable Components?
- Core Principles of Reusable Components
- Step-by-Step: Building a Reusable Component
- Advanced Patterns for Flexibility
- Testing Reusable Components
- Best Practices
- Conclusion
- 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;
Step 4: Centralize Types (Optional but Recommended)
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:
labelandonClickare provided (they’re required inButtonProps).variantandsizeuse 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., Modal → ModalHeader, 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.,
Tabto focus,Enter/Spaceto 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.