One type parameter instead of ten nearly identical components
A select component for products, a second one for users, a third for categories, each almost identical, only differing in the data type: this exact pattern is what generic React components solve. Instead of duplicating a component for every data type, the type itself becomes a parameter, and the compiler checks on every use whether data, callback, and rendering fit together. This article shows how generic React components are built in practice, including JSX syntax, constraints, and forwardRef.
Table of Contents
- 1. The problem: nearly identical components per data type
- 2. Basic syntax: a type parameter on a function component
- 3. Type inference in JSX: why the angle bracket sometimes gets in the way
- 4. Generic constraints: restricting a type parameter sensibly
- 5. Practical example: a generic select component
- 6. Combining generics with forwardRef
- 7. Default type parameters for rare edge cases
- 8. Multiple type parameters in one component
- 9. Generic, union, or any: the direct comparison
- 10. Summary
- 11. FAQ
1. The problem: nearly identical components per data type
In many React codebases, several components emerge over time that are structurally identical and differ only in the data type they process: a ProductSelect, a UserSelect, a CategorySelect, each with the same logic for selection, filtering, and rendering. The only difference lies in the concrete type of the options and the callback triggered on selection. This duplication contradicts the basic principle of implementing logic only once, and it creates multiple places that must be kept in sync whenever the shared logic changes.
Generic React components solve this problem exactly the way generics solve it for regular functions: the concrete data type is replaced by a type parameter that the compiler fills with the actual type on every use. A single Select<T> component can then replace ProductSelect, UserSelect, and CategorySelect at once, without losing type safety anywhere. The compiler knows exactly which type sits behind T at every concrete use and checks props, callback parameters, and the render function accordingly.
Switching from several specific components to one generic component pays off whenever the same structure already exists twice for different types or a third use is foreseeable. For a component that permanently works with only one concrete type, a generic solution is instead unnecessary complexity, a point the comparison section revisits in more detail later.
2. Basic syntax: a type parameter on a function component
The basic syntax for generic React components barely differs from generic functions: the type parameter sits in angle brackets right after the function name, followed by the normal parameter list with the props object. It matters that the component is declared as a named function, not as an arrow function constant, if it is meant to be generic, because the short syntax for generic arrow functions in .tsx files collides with JSX tag syntax and therefore needs a special notation with a comma after the type parameter.
For a generic component named List with type parameter T, T describes the type of the individual list items. Inside the component, T is then available for every use in props, state variables, or return values, exactly like a concrete type, just fixed by the compiler only once the component is actually used.
// Named function declaration works cleanly with a type parameter in .tsx
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Arrow function form needs a trailing comma to avoid JSX ambiguity in .tsx
const List2 = <T,>({ items, renderItem, keyExtractor }: ListProps<T>) => {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
};
3. Type inference in JSX: why the angle bracket sometimes gets in the way
When using a generic React component in JSX, the compiler automatically infers the concrete type from the supplied items prop in most cases, with no need to state the type parameter explicitly. Passing an array of type Product[] makes T become Product automatically, and every other prop like renderItem and keyExtractor gets checked against this inferred type. This automatic inference is the normal case and makes generic components just as convenient to use as non-generic ones in practice.
Only in rare cases, for example when items is an empty array and the compiler has nothing to infer from, does the type parameter need to be stated explicitly. In JSX this happens with the syntax <List<Product> items={[]} .../>. This explicit form is rarely needed but good to know, because the error message without it is sometimes not very informative and points, incorrectly, to a different spot in the code.
interface Product { id: number; name: string; price: number; }
const products: Product[] = [
{ id: 1, name: "Keyboard", price: 79 },
{ id: 2, name: "Monitor", price: 249 },
];
// T is inferred as Product from the "items" array, no explicit type argument needed
function ProductGrid() {
return (
<List
items={products}
renderItem={(product) => <span>{product.name}</span>}
keyExtractor={(product) => product.id}
/>
);
}
// Explicit type argument only needed when inference has nothing to work with
function EmptyGrid() {
return (
<List<Product>
items={[]}
renderItem={(product) => <span>{product.name}</span>}
keyExtractor={(product) => product.id}
/>
);
}
4. Generic constraints: restricting a type parameter sensibly
Not every type parameter should allow any arbitrary type without restriction. A generic table component, for instance, absolutely requires every element to have an id field for the React key, otherwise the component cannot be implemented safely. With T extends { id: string | number }, exactly this requirement is expressed as a constraint: the compiler only accepts types that at least have this field, rejecting any type without id already at compile time, long before the component is ever rendered.
Constraints make generic React components more robust because they explicitly document which minimum structure a data type must satisfy to be compatible with the component. Without a constraint, the component would either have to fall back to any or work with an additional, separate keyExtractor callback, which also works but means an extra prop and thus extra complexity at every call site.
// Constraint: T must at least have an "id" field for the React key
interface DataTableProps<T extends { id: string | number }> {
rows: T[];
columns: Array<{ header: string; render: (row: T) => ReactNode }>;
}
function DataTable<T extends { id: string | number }>({ rows, columns }: DataTableProps<T>) {
return (
<table>
<thead>
<tr>{columns.map((col) => <th key={col.header}>{col.header}</th>)}</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
{columns.map((col) => <td key={col.header}>{col.render(row)}</td>)}
</tr>
))}
</tbody>
</table>
);
}
// Compile error: Order has no "id" field
// interface Order { orderNumber: string; total: number; }
// <DataTable rows={orders} columns={...} />
5. Practical example: a generic select component
The classic example for generic React components is a reusable select component that is meant to work for products, users, categories, or any other data type, without a separate component being created for each type. The type parameter T describes the type of each individual option, while two callback props, getLabel and getValue, determine how the displayed text and the internal value, respectively, are derived from a concrete T value.
This structure completely separates the generic display logic from the concrete data shape. The select component itself does not need to know whether it displays products or users, it relies exclusively on the two callback functions. The compiler still ensures that onChange returns exactly the type T that the respective call site expects, so no manual type conversion is needed at the end of the selection chain.
interface SelectProps<T> {
options: T[];
value: T | null;
getLabel: (option: T) => string;
getValue: (option: T) => string;
onChange: (option: T) => void;
}
function Select<T>({ options, value, getLabel, getValue, onChange }: SelectProps<T>) {
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = options.find((opt) => getValue(opt) === e.target.value);
if (selected) onChange(selected);
};
return (
<select value={value ? getValue(value) : ""} onChange={handleChange}>
{options.map((opt) => (
<option key={getValue(opt)} value={getValue(opt)}>{getLabel(opt)}</option>
))}
</select>
);
}
// Usage with Product: T is inferred, onChange receives a full Product
// <Select
// options={products}
// value={selectedProduct}
// getLabel={(p) => p.name}
// getValue={(p) => String(p.id)}
// onChange={setSelectedProduct}
// />
6. Combining generics with forwardRef
Once a generic component additionally needs to forward a ref to a native DOM element, for example so the calling component can programmatically set focus, forwardRef comes into play. The challenge: forwardRef itself is not generically typed in older React type definitions, which causes the type parameter of the wrapped component to get lost on direct use and implicitly become unknown. In practice, you either work around this with an explicit type assertion on export or use the generic forwardRef signature that newer React type definitions provide.
The most reliable path remains implementing the component generically internally and restoring the type parameter on export via an assertion, because this technique works independent of the particular React type version. It matters to deliberately separate the internal name and the exported name, so the assertion sits in exactly one, clearly identifiable spot instead of being spread across the whole component.
import { forwardRef } from "react";
import type { Ref } from "react";
interface SearchInputProps<T> {
suggestions: T[];
getLabel: (item: T) => string;
onSelect: (item: T) => void;
}
// Internal implementation stays generic
function SearchInputInner<T>(
{ suggestions, getLabel, onSelect }: SearchInputProps<T>,
ref: Ref<HTMLInputElement>
) {
return (
<div>
<input ref={ref} type="text" placeholder="Search..." />
<ul>
{suggestions.map((item, i) => (
<li key={i} onClick={() => onSelect(item)}>{getLabel(item)}</li>
))}
</ul>
</div>
);
}
// Restore the generic type parameter with a single, explicit assertion at export
export const SearchInput = forwardRef(SearchInputInner) as <T>(
props: SearchInputProps<T> & { ref?: Ref<HTMLInputElement> }
) => ReturnType<typeof SearchInputInner>;
7. Default type parameters for rare edge cases
Some generic React components are used with the same type in the overwhelming majority of cases, but should still stay open for rare edge cases. For this situation, TypeScript allows a default type parameter, for example interface DropdownProps<T = string>. If the component is used without an explicit type argument and without a type derivable from props, the default type, here string, applies, without the call site needing to state anything extra.
This pattern significantly reduces boilerplate at the common call sites without losing flexibility for the rarer cases. A dropdown that displays simple text options nine times out of ten, but occasionally needs to handle more complex objects, benefits exactly from this combination of a default type and continued full genericity for the exceptional cases.
// Default type parameter: T falls back to string when nothing else fits
interface DropdownProps<T = string> {
options: T[];
onSelect: (option: T) => void;
}
function Dropdown<T = string>({ options, onSelect }: DropdownProps<T>) {
return (
<ul>
{options.map((opt, i) => (
<li key={i} onClick={() => onSelect(opt)}>{String(opt)}</li>
))}
</ul>
);
}
// Common case: no explicit type argument, defaults to string
// <Dropdown options={["EUR", "USD", "CHF"]} onSelect={setCurrency} />
// Rare case: explicit type argument overrides the default
// <Dropdown<Product> options={products} onSelect={setSelectedProduct} />
8. Multiple type parameters in one component
Just like generic functions, generic React components can also have more than one type parameter. A typical situation is a component that displays options of one type but produces a return value of a different type, for example a multi-select component that returns a list of IDs from a list of objects. Two independent type parameters, say T for the option object and V for the returned value, capture this relationship precisely.
It matters not to artificially overcomplicate the relationship between type parameters. In most cases, a single type parameter together with a callback that handles the derivation, as in the select example above, is enough. Two or more type parameters only pay off when the relationship between the types can genuinely vary independently, for example when the same option structure needs to be combinable with different return value types.
9. Generic, union, or any: the direct comparison
Not every component with multiple data types necessarily needs real generics. The following overview shows when a generic type parameter pays off and when a simpler alternative is sufficient.
| Situation | Unsuitable approach | Recommended approach | Reason |
|---|---|---|---|
| Only ever a single data type | List<T> with a single fixed call |
ProductList without a generic |
No benefit without real reuse |
| Two or three known variants | Generic with a constraint on a union | Discriminated union in the props | Clearer error messages, less abstraction |
| Arbitrarily many, unknown data types | items: any[] |
items: T[] with a generic |
Full type checking on every use |
| Structure with a minimum requirement | Generic without a constraint | T extends { id: … } |
Error already at compile time instead of runtime |
| Most common case should stay simple | Always state the type argument explicitly | Default type parameter T = string |
Shorter notation in the common case |
Generics are no substitute for discriminated unions when the number of variants is small and known. They only show their benefit once a component needs to work with arbitrarily many data types that are not fully known at design time. Anyone who keeps this distinction in mind avoids both unnecessarily complicated generic constructs for simple cases and unsafe any fallbacks for cases that would actually be fully typeable.
Mironsoft
TypeScript tooling, reusable React components and Magento/Hyvä integrations
Merge duplicated components into generic building blocks?
We identify structurally identical React components in your codebase and consolidate them into generic, fully type-safe building blocks with constraints, forwardRef support, and default type parameters.
Duplicate analysis
Identifying structurally similar components in the codebase
Generic refactoring
Consolidating into a single generic, type-safe component
Component library
Building a reusable, generic UI library for your team
10. Summary
Generic React components solve the problem of structurally identical components that only differ in the data type they process. A type parameter on the function component, combined with matching props for rendering and callbacks, replaces several nearly identical components with a single, fully type-safe implementation. Type inference in JSX handles most of the work automatically in practice, and constraints like T extends { id: … } additionally secure minimum requirements on the type parameter without limiting flexibility.
For edge cases like combining with forwardRef or a default type parameter for the most common use case, established solutions exist that involve no compromise on type safety. What matters is the boundary: with a small, known number of variants, a discriminated union is often the clearer choice, real generics only pay off once a component needs to work with arbitrarily many data types that are not fully known in advance.
Generic React Components - The Essentials at a Glance
Basic syntax
Type parameter right after the function name, a named function instead of an arrow function due to JSX collision.
Type inference
The compiler usually infers T automatically from the items prop, an explicit type argument is only needed for empty arrays.
Constraints & forwardRef
T extends { id: … } for minimum requirements, an assertion on export for generic forwardRef components.
When generics pay off
With arbitrarily many data types. With few known variants, a discriminated union is often clearer.