Generic Components: Reusable and Fully Typed
Abstraction in React often starts with copy-paste: another table, another list keyed by id,
another autocomplete wired to a different DTO. Generic components invert that workflow—you write the
structure once and let TypeScript carry the row type through columns, callbacks, and
children. The pain point they solve is subtle but expensive: without generics, you either lose type
information (any, string keys) or re-declare nearly identical prop types for every entity in your
app.
The goal is inference at the call site. Consumers pass data={users} and get User in
onRowClick without writing <Table<User> ... /> every time—though explicit type arguments remain
available when inference needs help.
Generic tables and keyof columns
A table is a textbook generic: rows are T[], columns refer to keys of T, and cell renderers
receive correctly typed values:
type TableProps<T> = {
data: T[]
columns: Array<{
key: keyof T
header: string
render?: (value: T[keyof T], row: T) => React.ReactNode
}>
onRowClick?: (row: T) => void
}
function Table<T>({ data, columns, onRowClick }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr
key={i}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
Usage demonstrates the win: invalid keys are a compile error, and the row in onRowClick is
whatever element type data has.
type User = { id: string; name: string; email: string }
declare const users: User[]
;<Table
data={users}
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
]}
onRowClick={(user) => {
console.log(user.id)
}}
/>
If someone adds { key: "nonexistent", header: "Oops" }, TypeScript rejects it—before the
broken column reaches QA. The “why” is risk reduction: schema drift between API types and UI columns
becomes a type error, not a blank cell in production.
Constrained generics for lists and keys
Lists keyed in React almost always need a stable identity. You can encode that requirement with
extends:
type ListProps<T extends { id: string | number }> = {
items: T[]
renderItem: (item: T) => React.ReactNode
}
function List<T extends { id: string | number }>({
items,
renderItem,
}: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))}
</ul>
)
}
The constraint documents an invariant: this list component assumes an id field exists for React’s
key and for any future optimizations (selection, virtualization). Callers with incompatible shapes
get a direct compiler message instead of a runtime warning about duplicate keys.
Accessor-based generics for non-object rows
Not every option type is a flat record with obvious keys. Autocomplete and combobox components often
work with unions, branded IDs, or domain types where display strings live behind functions.
Accessor props keep T opaque while still typing onChange:
type AutocompleteProps<T> = {
options: T[]
getLabel: (option: T) => string
getValue: (option: T) => string
onChange: (option: T) => void
}
function Autocomplete<T>({
options,
getLabel,
getValue,
onChange,
}: AutocompleteProps<T>) {
return (
<select
onChange={(e) => {
const selected = options.find(
(o) => getValue(o) === e.target.value,
)
if (selected) onChange(selected)
}}
>
{options.map((o) => (
<option key={getValue(o)} value={getValue(o)}>
{getLabel(o)}
</option>
))}
</select>
)
}
Here T might be a Country entity, a GraphQL node, or a string literal union—the component does
not care as long as callers supply the two accessors. The important part is that onChange receives
T, not string, so downstream code keeps domain semantics.
Generic hooks and end-to-end type flow
Components are not the only generic boundaries. Hooks like useLocalStorage<T> or useQuery<T>
should preserve T through state updates and setters so effects and callbacks stay aligned:
function useStableSelection<T>(items: T[]) {
const [selected, setSelected] = React.useState<T | null>(null);
const selectByIndex = React.useCallback(
(index: number) => {
setSelected(items[index] ?? null);
},
[items],
);
return { selected, selectByIndex, setSelected };
}
If items is User[], selected is User | null—no cast required when rendering a profile panel.
When inference fails
TypeScript’s inference for JSX generics is good but not magical. Common failure modes include:
- Empty arrays:
data={[]}givesnever[]. Fix with a default generic, explicit type argument, oras const/ annotated state. - Callbacks that widen: passing a function typed with a supertype can collapse
T. Annotate the callback parameter or add a generic bound on the prop. - Conditional rendering changing unions: if
datais sometimes missing, model that as a discriminated union on props rather than optionaldatawith genericT.
Default type parameters and explicit arguments
When inference fails—empty arrays, props from loosely typed parents—you can add a default generic:
function Table<T = Record<string, unknown>>(props: TableProps<T>) {
return null;
}
Defaults that are too wide (Record<string, unknown>) weaken autocomplete; prefer fixing the call
site (annotation, satisfies) when possible. Explicit <User> on JSX remains a valid escape hatch:
<Table<User> data={maybeUsers ?? []} columns={columns} />
Render props and children functions
Generics compose with render props: renderItem: (item: T, index: number) => React.ReactNode
carries T into the callback the same way onRowClick does. For children as a function, keep the
parent component generic over T so the child function’s parameter is not widened to unknown.
forwardRef and generics
forwardRef interacts awkwardly with generic inference. Many codebases use a thin typed wrapper or
accept a small inner cast. If the ref targets a fixed DOM node (HTMLButtonElement), tie the ref to
that node unless the rendered element truly varies—in which case you are closer to polymorphic
typing.
Design takeaway
Generic components pay off when the same layout repeats across many row types. The type
system’s job is to ensure columns, render props, and event handlers agree on that row type without
manual synchronization. Constrain when you must (id fields), use accessors when keys are not
enough, and treat generic hooks as part of the same story—one T from API to UI, not a broken
telephone of casts.