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={[]} gives never[]. Fix with a default generic, explicit type argument, or as 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 data is sometimes missing, model that as a discriminated union on props rather than optional data with generic T.

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.