Introduction
Generic components accept type parameters, making them flexible and reusable while maintaining type safety. Think Array<T> but for React components.
Key Concepts
Generic components use type parameters to create flexible, type-safe abstractions. The type flows through props, state, and callbacks.
Real World Context
Generics are essential for:
- List/table components that work with any data type
- Form libraries that handle any shape
- Select/dropdown components with typed options
- Data fetching hooks and components
Deep Dive
Basic Generic Component
tsxtype ListProps<T> = { items: T[]; renderItem: (item: T, index: number) => ReactNode; keyExtractor: (item: T) => string; }; function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map((item, index) => ( <li key={keyExtractor(item)}> {renderItem(item, index)} </li> ))} </ul> ); } // Usage - T is inferred as User <List items={users} renderItem={(user) => <span>{user.name}</span>} keyExtractor={(user) => user.id} />
Generic Select Component
tsxtype SelectProps<T> = { options: T[]; value: T | null; onChange: (value: T) => void; getLabel: (option: T) => string; getValue: (option: T) => string; }; function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) { return ( <select value={value ? getValue(value) : ''} onChange={(e) => { const selected = options.find( opt => getValue(opt) === e.target.value ); if (selected) onChange(selected); }} > <option value="">Select...</option> {options.map(option => ( <option key={getValue(option)} value={getValue(option)}> {getLabel(option)} </option> ))} </select> ); }
Constraining Generics
tsx// T must have an 'id' property type WithId = { id: string }; type DataTableProps<T extends WithId> = { data: T[]; columns: Array<{ key: keyof T; header: string; }>; }; function DataTable<T extends WithId>({ data, columns }: DataTableProps<T>) { return ( <table> <thead> <tr> {columns.map(col => ( <th key={String(col.key)}>{col.header}</th> ))} </tr> </thead> <tbody> {data.map(row => ( <tr key={row.id}> {columns.map(col => ( <td key={String(col.key)}> {String(row[col.key])} </td> ))} </tr> ))} </tbody> </table> ); }
Generic Hooks
tsxfunction useLocalStorage<T>( key: string, initialValue: T ): [T, (value: T) => void] { const [storedValue, setStoredValue] = useState<T>(() => { const item = localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; }); const setValue = (value: T) => { setStoredValue(value); localStorage.setItem(key, JSON.stringify(value)); }; return [storedValue, setValue]; } // Usage with inference const [settings, setSettings] = useLocalStorage('settings', { theme: 'dark' }); // settings is typed as { theme: string }
Common Pitfalls
- Over-constraining: Adding unnecessary constraints limits reusability.
- Not inferring when possible: Let TypeScript infer from arguments rather than requiring explicit type parameters.
- Losing type information: Using
anyin generics defeats the purpose.
Best Practices
- Let TypeScript infer generic types from props when possible
- Use constraints (
extends) only when you need to access specific properties - Provide meaningful type parameter names (
T,TItem,TData) - Export the props type for consumers who need it
- Consider whether a generic is needed - sometimes a union type suffices
Summary
Generic components create flexible, reusable abstractions while maintaining full type safety. Use them for lists, tables, selects, and any component that works with arbitrary data types. Let TypeScript infer when possible.
Code Examples
tsx
import { useState, ReactNode } from 'react';
// Generic async data component
type AsyncDataProps<T> = {
promise: Promise<T>;
loading: ReactNode;
error: (err: Error) => ReactNode;
children: (data: T) => ReactNode;
};
function AsyncData<T>({
promise,
loading,
error,
children
}: AsyncDataProps<T>) {
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
>({ status: 'loading' });
// Effect to resolve promise...
switch (state.status) {
case 'loading':
return <>{loading}</>;
case 'error':
return <>{error(state.error)}</>;
case 'success':
return <>{children(state.data)}</>;
}
}
// Usage with full type inference
type Product = { id: string; name: string; price: number };
<AsyncData<Product>
promise={fetchProduct(id)}
loading={<Spinner />}
error={(err) => <Error message={err.message} />}
>
{(product) => (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
)}
</AsyncData>
// Generic modal/dialog component
type ModalProps<T> = {
isOpen: boolean;
onClose: () => void;
onConfirm: (data: T) => void;
initialData: T;
children: (data: T, setData: (data: T) => void) => ReactNode;
};
function Modal<T>({
isOpen,
onClose,
onConfirm,
initialData,
children
}: ModalProps<T>) {
const [data, setData] = useState(initialData);
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<div className="modal">
{children(data, setData)}
<div className="modal-actions">
<button onClick={onClose}>Cancel</button>
<button onClick={() => onConfirm(data)}>Confirm</button>
</div>
</div>
</div>
);
}