I didn’t want to write about using Generics in React Components until last week, when I received two comments from the engineers on my team.
The syntax looks invalid
The usage of generics throws me off
At that moment, I realised that this was an opportunity to share what I knew about using TypeScript Generics in React Components that could help some engineers, and get the team aligned on why, how, and when to use generics.
1. Introduction: Why Generics in React Components?
When building React apps with TypeScript, you’ll often find yourself creating components that work with different types of data but follow the same structure. Without generics, you might end up with repetitive code or lose type safety.
Consider this common scenario: you have a list component that displays users in one place and products in another. Without generics, you might create separate components or use any
types, both of which lead to problems.
The problem with non-generic components:
// Without generics - not reusable
interface UserListProps {
items: User[];
onSelect: (item: User) => void;
}
interface ProductListProps {
items: Product[];
onSelect: (item: Product) => void;
}
// Or worse - lose type safety
interface GenericListProps {
items: any[];
onSelect: (item: any) => void;
}
Type safety benefits of generics include catching errors at compile time, better IntelliSense support, and self-documenting code. Code reusability and maintainability improve because you write the logic once and apply it to multiple types.
2. TypeScript Generics Fundamentals
Before diving into React-specific patterns, let’s review the basics of TypeScript generics.
Generic syntax uses angle brackets to define type parameters:
function echo<T>(arg: T): T {
return arg;
}
// Usage
const result = echo<string>("hello"); // T is string
const number = echo(42); // T is inferred as number
Generic constraints and bounds limit what types can be used:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength("hello"); // Works - string has length
logLength([1, 2, 3]); // Works - array has length
// logLength(42); // Error - number doesn't have length
Default generic parameters provide fallback types:
interface ApiResponse<TData = unknown> {
data: TData;
status: number;
}
// Uses default unknown type
const response: ApiResponse = { data: {}, status: 200 };
// Explicit type
const userResponse: ApiResponse<User> = { data: user, status: 200 };
3. Your First Generic React Component
Let’s convert a simple component to use generics. Here’s a basic list component:
// Non-generic version
interface User {
id: number;
name: string;
email: string;
}
interface UserListProps {
users: User[];
onUserSelect: (user: User) => void;
}
function UserList({ users, onUserSelect }: UserListProps) {
return (
<ul>
{users.map(user => (
<li key={user.id} onClick={() => onUserSelect(user)}>
{user.name}
</li>
))}
</ul>
);
}
Now let’s make it generic:
// Generic version
interface ListItem {
id: number | string;
}
interface GenericListProps<T extends ListItem> {
items: T[];
onItemSelect: (item: T) => void;
renderItem: (item: T) => React.ReactNode;
}
function GenericList<T extends ListItem>({
items,
onItemSelect,
renderItem
}: GenericListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemSelect(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
Use generics:
// Usage with explicit type
<GenericList<User>
items={users}
onItemSelect={handleUserSelect}
renderItem={(user) => user.name}
/>
// TypeScript can often infer the type
<GenericList
items={products} // TypeScript infers Product[]
onItemSelect={handleProductSelect}
renderItem={(product) => product.title}
/>
Props interface with generic types ensures type safety throughout the component. The T extends ListItem
constraint ensures all items have an id
property for the key
prop.
Type safety? Yes.
Imagine you pass the wrong handler to the list.
product.title}
/>
TypeScript will pick it up before shipping this bug to your pipeline or your customers.
An example can be found here.
4. Common Patterns and Use Cases
Generic list components are perfect for displaying collections of any type:
interface TableColumn<T> {
key: keyof T;
title: string;
render?: (value: T[keyof T], item: T) => React.ReactNode;
}
interface DataTableProps<T> {
data: T[];
columns: TableColumn<T>[];
onRowClick?: (item: T) => void;
}
function DataTable<T>({ data, columns, onRowClick }: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)}>{col.title}</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr key={index} onClick={() => onRowClick?.(item)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(item[col.key], item)
: String(item[col.key])
}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Form components with typed values:
interface FormFieldProps<T> {
value: T;
onChange: (value: T) => void;
label: string;
error?: string;
}
function FormField<T extends string | number>({
value,
onChange,
label,
error
}: FormFieldProps<T>) {
return (
<div>
<label>{label}</label>
<input
type={typeof value === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => {
const newValue = typeof value === 'number'
? Number(e.target.value) as T
: e.target.value as T;
onChange(newValue);
}}
/>
{error && <span className="error">{error}</span>}
</div>
);
}
Modal components with typed data:
interface ModalProps<T> {
isOpen: boolean;
onClose: () => void;
data: T;
renderContent: (data: T) => React.ReactNode;
title?: string;
}
function Modal<T>({
isOpen,
onClose,
data,
renderContent,
title
}: ModalProps<T>) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{title && <h2>{title}</h2>}
<button onClick={onClose}>×</button>
{renderContent(data)}
</div>
</div>
);
}
// Usage
<Modal<User>
isOpen={showUserModal}
onClose={() => setShowUserModal(false)}
data={selectedUser}
title="User Details"
renderContent={(user) => (
<div>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
)}
/>
5. Prop Inference and Type Safety
TypeScript’s type inference makes generic components feel natural to use. Here’s how it works:
How TypeScript infers generic types from props:
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string | number;
}
function Select<T>(props: SelectProps<T>) {
// Component implementation
}
// TypeScript infers T as User from the options prop
<Select
options={users} // User[]
value={selectedUser} // User
onChange={setSelectedUser} // (user: User) => void
getLabel={(user) => user.name} // user is typed as User
getValue={(user) => user.id} // user is typed as User
/>
Explicit vs implicit type parameters:
// Explicit - useful when inference isn't possible
<Select<User>
options={[]}
value={undefined}
onChange={handleChange}
getLabel={(user) => user.name}
getValue={(user) => user.id}
/>
// Implicit - TypeScript figures it out
<Select
options={users}
value={selectedUser}
onChange={handleChange}
getLabel={(user) => user.name}
getValue={(user) => user.id}
/>
Type narrowing in component props happens when you provide more specific constraints:
interface SearchableItem {
searchableText: string;
}
interface SearchableListProps<T extends SearchableItem> {
items: T[];
onSearch: (query: string, items: T[]) => T[];
}
function SearchableList<T extends SearchableItem>(props: SearchableListProps<T>) {
// T is guaranteed to have searchableText property
const filteredItems = props.items.filter(item =>
item.searchableText.toLowerCase().includes(searchQuery.toLowerCase())
);
}
6. Advanced Generic Patterns
Multiple generic parameters allow for complex component relationships:
interface KeyValuePair<K, V> {
key: K;
value: V;
}
interface KeyValueListProps<K, V> {
pairs: KeyValuePair<K, V>[];
onPairSelect: (key: K, value: V) => void;
renderKey: (key: K) => React.ReactNode;
renderValue: (value: V) => React.ReactNode;
}
function KeyValueList<K, V>({
pairs,
onPairSelect,
renderKey,
renderValue
}: KeyValueListProps<K, V>) {
return (
<div>
{pairs.map((pair, index) => (
<div
key={index}
onClick={() => onPairSelect(pair.key, pair.value)}
>
<span>{renderKey(pair.key)}</span>:
<span>{renderValue(pair.value)}</span>
</div>
))}
</div>
);
}
Generic constraints with extends
provide powerful type checking:
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface TimelineProps<T extends Timestamped> {
items: T[];
renderItem: (item: T) => React.ReactNode;
sortOrder: 'asc' | 'desc';
}
function Timeline<T extends Timestamped>({
items,
renderItem,
sortOrder
}: TimelineProps<T>) {
const sortedItems = [...items].sort((a, b) => {
const dateA = a.createdAt.getTime();
const dateB = b.createdAt.getTime();
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
});
return (
<div className="timeline">
{sortedItems.map((item, index) => (
<div key={index} className="timeline-item">
<time>{item.createdAt.toLocaleDateString()}</time>
{renderItem(item)}
</div>
))}
</div>
);
}
Conditional types in component props:
type ConditionalProps<T> = T extends string
? { stringProp: string }
: T extends number
? { numberProp: number }
: { genericProp: T };
interface ConditionalComponentProps<T> extends ConditionalProps<T> {
value: T;
}
function ConditionalComponent<T>(props: ConditionalComponentProps<T>) {
// Component logic based on conditional props
}
Generic utility types (Pick
, Omit
, Partial
):
interface FullUser {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
interface UserCardProps<T extends Pick<FullUser, 'id' | 'name' | 'email'>> {
user: T;
onEdit?: (user: T) => void;
showEmail?: boolean;
}
function UserCard<T extends Pick<FullUser, 'id' | 'name' | 'email'>>({
user,
onEdit,
showEmail = true
}: UserCardProps<T>) {
return (
<div className="user-card">
<h3>{user.name}</h3>
{showEmail && <p>{user.email}</p>}
{onEdit && <button onClick={() => onEdit(user)}>Edit</button>}
</div>
);
}
7. Generic Hooks and Custom Logic
Creating generic custom hooks:
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue] as const;
}
// Usage in component
function UserPreferences() {
const [preferences, setPreferences] = useLocalStorage<UserPrefs>('userPrefs', {
theme: 'light',
language: 'en'
});
}
Combining generic components with generic hooks:
function useAsyncData<T>(fetchFn: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetchFn()
.then(setData)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}
interface AsyncDataDisplayProps<T> {
fetchFn: () => Promise<T>;
renderData: (data: T) => React.ReactNode;
renderLoading?: () => React.ReactNode;
renderError?: (error: string) => React.ReactNode;
}
function AsyncDataDisplay<T>({
fetchFn,
renderData,
renderLoading = () => <div>Loading...</div>,
renderError = (error) => <div>Error: {error}</div>
}: AsyncDataDisplayProps<T>) {
const { data, loading, error } = useAsyncData(fetchFn);
if (loading) return renderLoading();
if (error) return renderError(error);
if (!data) return null;
return <>{renderData(data)}</>;
}
8. Real-World Examples
Building a generic data fetcher component:
interface ApiConfig {
baseURL: string;
headers?: Record<string, string>;
}
interface DataFetcherProps<T> {
url: string;
config?: ApiConfig;
transform?: (data: any) => T;
renderData: (data: T) => React.ReactNode;
renderLoading?: () => React.ReactNode;
renderError?: (error: Error) => React.ReactNode;
}
function DataFetcher<T>({
url,
config = { baseURL: '' },
transform = (data) => data,
renderData,
renderLoading = () => <div>Loading...</div>,
renderError = (error) => <div>Error: {error.message}</div>
}: DataFetcherProps<T>) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: Error | null;
}>({
data: null,
loading: true,
error: null
});
useEffect(() => {
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const response = await fetch(`${config.baseURL}${url}`, {
headers: config.headers
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawData = await response.json();
const transformedData = transform(rawData);
setState({ data: transformedData, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error('Unknown error')
});
}
};
fetchData();
}, [url, config.baseURL]);
if (state.loading) return renderLoading();
if (state.error) return renderError(state.error);
if (!state.data) return null;
return <>{renderData(state.data)}</>;
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
<DataFetcher<User[]>
url="/api/users"
config={{ baseURL: 'https://api.example.com' }}
transform={(data) => data.users}
renderData={(users) => (
<ul>
{users.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
)}
/>
Creating a reusable form field component:
interface ValidationRule<T> {
validate: (value: T) => boolean;
message: string;
}
interface FormFieldProps<T> {
label: string;
value: T;
onChange: (value: T) => void;
type?: 'text' | 'email' | 'number' | 'password';
placeholder?: string;
required?: boolean;
validationRules?: ValidationRule<T>[];
renderInput?: (props: {
value: T;
onChange: (value: T) => void;
hasError: boolean;
}) => React.ReactNode;
}
function FormField<T extends string | number>({
label,
value,
onChange,
type = 'text',
placeholder,
required = false,
validationRules = [],
renderInput
}: FormFieldProps<T>) {
const [touched, setTouched] = useState(false);
const errors = validationRules
.filter(rule => !rule.validate(value))
.map(rule => rule.message);
const hasError = touched && errors.length > 0;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = type === 'number'
? Number(e.target.value) as T
: e.target.value as T;
onChange(newValue);
};
const defaultInput = (
<input
type={type}
value={value}
onChange={handleChange}
onBlur={() => setTouched(true)}
placeholder={placeholder}
className={hasError ? 'error' : ''}
required={required}
/>
);
return (
<div className="form-field">
<label>
{label}
{required && <span className="required">*</span>}
</label>
{renderInput
? renderInput({ value, onChange, hasError })
: defaultInput
}
{hasError && (
<div className="error-messages">
{errors.map((error, index) => (
<span key={index} className="error-message">
{error}
</span>
))}
</div>
)}
</div>
);
}
// Usage
const emailValidation: ValidationRule<string> = {
validate: (value) => /^[^s@]+@[^s@]+.[^s@]+$/.test(value),
message: 'Please enter a valid email address'
};
<FormField<string>
label="Email"
value={email}
onChange={setEmail}
type="email"
required
validationRules={[emailValidation]}
/>
9. Best Practices and Common Pitfalls
When to use generics vs unions:
Use generics when you need the same structure with different types:
// Good use of generics
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
Use unions when you have a limited set of known types:
// Good use of unions
interface ButtonProps {
variant: 'primary' | 'secondary' | 'danger';
size: 'small' | 'medium' | 'large';
}
Naming conventions for generic parameters:
- Use
T
for a single generic type - Use descriptive names for multiple parameters:
TData
,TError
,TKey
,TValue
- Use
T
prefix for generic type parameters to distinguish from regular types
// Good naming
interface DataTableProps<TData, TColumn> {
data: TData[];
columns: TColumn[];
}
// Clear and descriptive
interface ApiHookResult<TData, TError = Error> {
data: TData | null;
error: TError | null;
loading: boolean;
}
Performance considerations:
- Generic components don’t have runtime overhead
- TypeScript generics are compile-time only
- Be mindful of complex conditional types that can slow down compilation
Debugging generic type errors:
// Use type assertions for debugging
const debugType = <T>(value: T): T => {
console.log('Type:', typeof value, 'Value:', value);
return value;
};
// Use utility types to inspect complex types
type InspectProps<T> = {
[K in keyof T]: T[K]
};
10. Integration with Popular Libraries
Generics with React Query/SWR:
import { useQuery } from '@tanstack/react-query';
interface QueryComponentProps<TData, TError = Error> {
queryKey: string[];
queryFn: () => Promise<TData>;
renderData: (data: TData) => React.ReactNode;
renderError?: (error: TError) => React.ReactNode;
}
function QueryComponent<TData, TError = Error>({
queryKey,
queryFn,
renderData,
renderError = (error) => <div>Error: {String(error)}</div>
}: QueryComponentProps<TData, TError>) {
const { data, error, isLoading } = useQuery<TData, TError>({
queryKey,
queryFn
});
if (isLoading) return <div>Loading...</div>;
if (error) return renderError(error);
if (!data) return null;
return <>{renderData(data)}</>;
}
Form libraries (React Hook Form):
import { useForm, FieldValues, Path } from 'react-hook-form';
interface FormInputProps<T extends FieldValues> {
name: Path<T>;
label: string;
register: ReturnType<typeof useForm<T>>['register'];
errors: ReturnType<typeof useForm<T>>['formState']['errors'];
type?: string;
}
function FormInput<T extends FieldValues>({
name,
label,
register,
errors,
type = 'text'
}: FormInputProps<T>) {
const error = errors[name];
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
{...register(name)}
className={error ? 'error' : ''}
/>
{error && <span className="error">{error.message}</span>}
</div>
);
}
11. Testing Generic Components
Writing tests for generic components:
import { render, screen, fireEvent } from '@testing-library/react';
import { GenericList } from './GenericList';
interface TestItem {
id: number;
name: string;
}
describe('GenericList', () => {
const mockItems: TestItem[] = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
];
it('renders items correctly', () => {
const mockOnSelect = jest.fn();
render(
<GenericList<TestItem>
items={mockItems}
onItemSelect={mockOnSelect}
renderItem={(item) => item.name}
/>
);
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
it('calls onItemSelect with correct type', () => {
const mockOnSelect = jest.fn();
render(
<GenericList<TestItem>
items={mockItems}
onItemSelect={mockOnSelect}
renderItem={(item) => item.name}
/>
);
fireEvent.click(screen.getByText('Item 1'));
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]);
// TypeScript ensures the parameter is TestItem, not any
});
});
Mocking generic props:
// Create typed mocks for better test safety
const createMockProps = <T>(): GenericListProps<T> => ({
items: [] as T[],
onItemSelect: jest.fn(),
renderItem: jest.fn().mockReturnValue('mocked content')
});
// Use in tests
const mockProps = createMockProps<TestItem>();
Type assertion strategies:
// When you need to assert types in tests
const assertType = <T>(value: unknown): asserts value is T => {
// Runtime type checking logic if needed
};
// Or use TypeScript's assertion functions
function isTestItem(item: unknown): item is TestItem {
return typeof item === 'object' &&
item !== null &&
'id' in item &&
'name' in item;
}
Conclusion
TypeScript generics in React components provide a powerful way to create reusable, type-safe components that work with different data types while maintaining excellent developer experience. By following the patterns and best practices outlined in this guide, your team can build more maintainable and robust React applications.
The key is to start simple with basic generic components and gradually adopt more advanced patterns as your needs grow. Remember that generics should make your code more reusable and type-safe, not more complex. When in doubt, prefer explicit types over overly complex generic constraints. (Yes, I’ve seen too many over-engineering examples, including all of mine from the past decades)
Start implementing these patterns in your codebase today, and you’ll find that your components become more flexible, your code becomes more maintainable, and your development experience improves significantly.
References and Further Reading
Official Documentation
- TypeScript Handbook – Generics – The official TypeScript documentation covering generics fundamentals
- React TypeScript Documentation – Official TypeScript documentation for React integration
- React – Using TypeScript – React’s official guide on TypeScript integration
In-Depth Articles and Tutorials
- TypeScript Generics for React Developers – Comprehensive guide explaining generics concepts with React-specific examples
- How to Use TypeScript Generics with Functional React Components – FreeCodeCamp tutorial exploring generics with functional React components
- Generic React Components in TypeScript – Tutorial on building reusable, type-safe components with practical examples
- Code React Components Using TypeScript Generics – MojoTech’s guide to advanced TypeScript generics in React
Practical Guides and Tips
- Use Generics in React to Make Dynamic Components – Total TypeScript’s practical tip on creating flexible components
- React & TypeScript: Use Generics to Improve Your Types – Devtrium’s guide to understanding and implementing generics in React
- Create a React/TypeScript Generic Component – DEV Community tutorial on creating reusable generic components
Community Resources
- React TypeScript Cheatsheet – Generic Components – Community-maintained cheatsheet with advanced generic component patterns
- Building Reusable Components with TypeScript Generics – GeeksforGeeks tutorial on component reusability with generics
These references provide a mix of official documentation, practical tutorials, and community resources that complement the topics covered in this guide. They range from beginner-friendly explanations to advanced patterns, giving your team multiple perspectives and learning paths for mastering TypeScript generics in React.