Generic Components: Tables, Selects, and Forms
Learn to design React components that are generic over the data they work with — tables, selects, and forms — so a single component stays fully type-safe across any data shape it's used with, instead of relying on any or a separate component per shape.
Plenty of components have logic that doesn't care what shape the data is — a table just needs to render rows, a select just needs to render options, a form just needs to collect and validate fields — but still needs to be fully type-safe for whatever specific shape is passed in at each call site. TypeScript generics applied to components solve exactly this: one Table<T> component works correctly for a list of users, a list of orders, or a list of anything else, with full autocomplete and type checking specific to each usage.
Generic props and generic callbacks
A generic component's props type is written with a type parameter, just like a generic function's parameter list: type TableProps<T> = { data: T[]; renderRow: (item: T) => React.ReactNode }. When you use <Table data={users} renderRow={...} />, TypeScript infers T as User from the data array, and that inferred T then flows into renderRow's parameter automatically — so inside renderRow, the parameter is fully typed as User with autocomplete on every field, with no manual casting anywhere.
This is the core mechanic behind every generic component: one or more props (usually an array, a single value, or a callback's return type) act as the 'source' TypeScript infers T from, and every other prop that mentions T — callbacks, default values, event handlers — gets that same inferred type applied consistently. The component's internals don't need to know what T actually is; they only need to satisfy the constraints the generic type describes.
A generic callback prop like renderRow: (item: T) => React.ReactNode or onSelect: (item: T) => void is what lets consumer code interact with the specific data shape safely, while the component itself stays completely agnostic — it just calls renderRow(item) for each item in the array without ever needing to know what fields item has.
Constraints: bounding what T can be
Sometimes a generic component needs T to have at least some particular shape — for instance, a Table that needs a unique key for each row requires every T to have some kind of identifier. This is expressed with a generic constraint: function Table<T extends { id: string | number }>({ data, renderRow }: TableProps<T>), which restricts T to only types that include an id field of the right type, while still letting T have any number of additional fields beyond that.
Constraints are a middle ground between fully unconstrained generics (any shape at all) and a fixed, non-generic type (exactly one shape) — they let the component both stay reusable across many data shapes and rely on the one or two fields it actually needs internally, like the identifier used for React's key prop.
It's worth noting the constraint only needs to describe what the component's own internals actually touch — over-constraining T (requiring fields the component never uses) needlessly narrows which data shapes can be passed in, defeating some of the point of making the component generic in the first place.
Worked example: a generic Select
A generic Select<T> follows the same shape as Table<T>: it accepts options: T[], plus a function to turn each option into display text (getLabel: (item: T) => string) and a callback fired with the selected item (onChange: (item: T) => void). Internally, the component only needs to track which index is selected — it never needs to inspect the actual shape of T beyond calling the two functions it was given, which is exactly what makes it reusable for a list of strings, a list of user objects, or a list of anything else.
This pattern — 'accept data plus functions that know how to interpret that data' — is the general recipe for making almost any presentational component generic: the component owns the rendering and interaction logic, while the caller supplies the small pieces of shape-specific knowledge (how to label an item, how to render a row, how to get a unique key) through typed callback props.
Worked example: a generic form
Generic forms extend the same idea to a whole set of fields at once. A Form<T> component might accept initialValues: T, onSubmit: (values: T) => void, and expose a typed values object and a typed setField updater — setField<K extends keyof T>(key: K, value: T[K]) — so that calling setField('email', 123) on a form typed with { email: string; age: number } is a compile error, while setField('email', '[email protected]') and setField('age', 30) both type-check correctly against the matching field.
This nested generic — a type parameter K constrained by extends keyof T, used inside a component that's already generic over T — is a common and powerful pattern once you're comfortable with basic generic components: it lets a single, very general form abstraction still catch field-specific typos and type mismatches for whatever concrete form shape it's used with.
Generic table component
T is inferred from the data array's element type at each usage — no explicit <T> needed at the call site in most cases. Prefer a plain function declaration for generic components in .tsx files; a generic arrow function needs a trailing comma (<T,>) to avoid JSX ambiguity.
type TableProps<T> = {
data: T[];
renderRow: (item: T) => React.ReactNode;
};
function Table<T>({ data, renderRow }: TableProps<T>) {
return <>{data.map(renderRow)}</>;
}Code Examples
The constraint T extends { id: number | string } lets the component use item.id as the React key internally, while still allowing any data shape that includes an id field. renderRow's parameter u is correctly typed as User, with autocomplete on u.name. Expected result: Renders one row per user; passing data without an id field is a compile-time error.
T is inferred as Currency from the options array; getLabel and onChange both receive a fully typed Currency parameter, so c.code and c.label autocomplete correctly and a typo like c.currencyCode is caught at compile time.
onChange={(c) => console.log(c.code)}
type User = { id: number; name: string };
function Table<T extends { id: number | string }>({
data,
renderRow,
}: {
data: T[];
renderRow: (item: T) => React.ReactNode;
}) {
return (
<table>
<tbody>
{data.map((item) => (
<React.Fragment key={item.id}>{renderRow(item)}</React.Fragment>
))}
</tbody>
</table>
);
}
// Usage — T is inferred as User
<Table<User>
data={users}
renderRow={(u) => <tr><td>{u.name}</td></tr>}
/>;type SelectProps<T> = {
options: T[];
getLabel: (item: T) => string;
onChange: (item: T) => void;
};
function Select<T>({ options, getLabel, onChange }: SelectProps<T>) {
return (
<select onChange={(e) => onChange(options[Number(e.target.value)])}>
{options.map((opt, i) => (
<option key={i} value={i}>
{getLabel(opt)}
</option>
))}
</select>
);
}
type Currency = { code: string; label: string };
const currencies: Currency[] = [
{ code: 'USD', label: 'US Dollar' },
{ code: 'EUR', label: 'Euro' },
];
<Select<Currency>
options={currencies}
getLabel={(c) => `${c.code} — ${c.label}`}
onChange={(c) => console.log(c.code)}
/>;How T flows through a generic component
T is inferred once from a source prop and then applied consistently across every other prop that mentions it.
<Table data={users} renderRow={(u) => ...} />
| |
v v
T inferred as User u typed as User
| |
+---- same T --------+
One component definition, many call-site types (User, Order, Product, ...)Important Rules
Let TypeScript infer T from a props value (usually an array or single item) whenever possible; only specify it explicitly (<Table<User> ...>) when there's nothing for TypeScript to infer from.
Constrain T only to the shape the component's internals actually need (e.g. extends { id: ... } for keys) — don't over-constrain and needlessly narrow which data can be passed in.
Prefer a plain function declaration over an arrow function for generic components in .tsx files, to avoid the <T> vs. JSX ambiguity.
Keep generic components 'shape-agnostic' internally — let callback props (renderRow, getLabel, onChange) carry all the shape-specific knowledge.
Where this matters
Reusable data-display components in production design systems — tables, selects, autocomplete lists, dropdown menus, comboboxes — are almost always generic, so the same component ships once in the shared library but stays fully type-safe for every different data shape (users, products, orders, currencies) it's used with across dozens of different teams and features.
function Table({ data, renderRow }: TableProps<any>) {... } function Table<T>({ data, renderRow }: TableProps<T>) {... }
Typing the props as TableProps<any> throws away the type relationship between data and renderRow entirely, defeating the purpose of a generic component and allowing renderRow's parameter to silently mismatch the data array's actual element type.
const Table = <T>({ data, renderRow }: TableProps<T>) => <>{data.map(renderRow)}</>;
const Table = <T,>({ data, renderRow }: TableProps<T>) => <>{data.map(renderRow)}</>;
In a .tsx file, <T> alone at the start of an arrow function's type parameter list is ambiguous with JSX syntax and fails to parse; the trailing comma in <T,> disambiguates it (or switch to a plain function declaration, which has no such ambiguity).
[multiple-choice] In function Table<T>({ data, renderRow }: TableProps<T>), how does TypeScript usually determine what T is at a given call site? A. You must always write <Table<User>> explicitly B. It infers T from the data array's element type passed to the data prop C. T defaults to any unless specified D. It infers T from the component's file name
B. It infers T from the data array's element type passed to the data prop — TypeScript infers generic type parameters from the arguments (or in this case, props) passed at the call site, the same way it infers T for a generic function from its arguments — data={users} infers T as User.
[multiple-choice] Why might you write function Table<T extends { id: number | string }>(...) instead of leaving T unconstrained? A. To make the component run faster B. Because the component's internals need to rely on an id field (e.g. for the React key prop), so T must be constrained to guarantee it exists C. Constraints are required syntax for all generic components D. To prevent the component from being generic at all
B. Because the component's internals need to rely on an id field (e.g. for the React key prop), so T must be constrained to guarantee it exists — A constraint bounds T to types that include whatever fields the component's own logic actually needs — here, an id used as a React key — while still allowing any data shape with additional fields beyond that.
[true-false] True or false: a generic arrow function component in a .tsx file can be written as const Table = <T>(props: TableProps<T>) => ... without any special syntax. A. True B. False
B. False — In a .tsx file, <T> alone is ambiguous with JSX syntax, so a generic arrow function component needs a trailing comma (<T,>) or a type constraint to disambiguate — or you can sidestep the issue entirely with a plain function declaration.
Write a generic Form<T> component with props { initialValues: T; onSubmit: (values: T) => void; children: (values: T, setField: <K extends keyof T>(key: K, value: T[K]) => void) => React.ReactNode }. It should hold values in state and call onSubmit(values) when a wrapped <form> is submitted.
type FormProps<T> = { initialValues: T; onSubmit: (values: T) => void; children: ( values: T, setField: <K extends keyof T>(key: K, value: T[K]) => void ) => React.ReactNode; }; function Form<T>({ initialValues, onSubmit, children }: FormProps<T>) { const [values, setValues] = React.useState<T>(initialValues); function setField<K extends keyof T>(key: K, value: T[K]) { setValues((prev) => ({ ...prev, [key]: value })); } return ( <form onSubmit={(e) => { e.preventDefault(); onSubmit(values); }} > {children(values, setField)} </form> ); }
Use useState<T>(initialValues) for values, and a setField function that updates one key at a time with setValues(prev => ({ ...prev, [key]: value })).