Generic React Components

Difficulty: Advanced

Generic React components are components that accept a type parameter, allowing them to work with different data types while maintaining full type safety. This is one of the most powerful patterns in TypeScript React development. A generic List component, for example, can render a list of any item type while keeping the onSelect callback fully typed to that specific item type.

To create a generic component, you define the component function with a type parameter: `function List<T>(props: ListProps<T>)`. The type parameter T flows through the props interface, connecting the items array type to the renderItem function type to the onSelect callback type. When the component is used, TypeScript infers T from the items prop, so consumers do not need to specify the generic type explicitly in most cases.

Constrained generics add requirements to the type parameter using the `extends` keyword. For example, `<T extends { id: string }>` ensures that whatever type is used must have an `id` property. This is essential for components that need to access certain properties on the items - like using `item.id` as a React key. Without the constraint, TypeScript would not allow accessing `.id` on the generic type.

Generic components are particularly useful for building reusable UI libraries. Common examples include Select/Dropdown components (generic over the option type), Table components (generic over the row data type), and Form components (generic over the form values type). The key insight is that the generic type connects related props - the data source, the render function, and the event callbacks all share the same type, ensuring consistency.

Code examples

Generic List Component

// Generic list component simulation
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => string;
  onSelect?: (item: T) => void;
}

function List<T>(props: ListProps<T>): string {
  const rendered = props.items.map((item, i) => {
    if (props.onSelect) props.onSelect(item);
    return props.renderItem(item, i);
  });
  return rendered.join('\n');
}

// TypeScript infers T as { name: string; age: number }
const output = List({
  items: [
    { name: 'Alice', age: 30 },
    { name: 'Bob', age: 25 }
  ],
  renderItem: (user, i) => `${i + 1}. ${user.name} (${user.age})`,
  onSelect: (user) => {} // user is typed as { name: string; age: number }
});

console.log(output);

T is inferred from the items array. The renderItem and onSelect callbacks automatically know the exact type of each item.

Constrained Generic Component

// Generic component with constraints
interface HasId {
  id: string | number;
}

interface SelectProps<T extends HasId> {
  options: T[];
  value: T['id'];
  getLabel: (option: T) => string;
  onChange: (selected: T) => void;
}

function Select<T extends HasId>(props: SelectProps<T>): string {
  const selected = props.options.find(opt => opt.id === props.value);
  const labels = props.options.map(opt => {
    const label = props.getLabel(opt);
    const marker = opt.id === props.value ? ' [selected]' : '';
    return `  - ${label}${marker}`;
  });
  return `Select:\n${labels.join('\n')}`;
}

interface Country {
  id: string;
  name: string;
  code: string;
}

const countries: Country[] = [
  { id: 'us', name: 'United States', code: 'US' },
  { id: 'uk', name: 'United Kingdom', code: 'UK' },
  { id: 'jp', name: 'Japan', code: 'JP' }
];

const result = Select({
  options: countries,
  value: 'uk',
  getLabel: (country) => `${country.name} (${country.code})`,
  onChange: (country) => {} // country is fully typed as Country
});

console.log(result);

The 'extends HasId' constraint ensures T always has an 'id' property. This allows the component to use item.id for comparisons while keeping the full type for callbacks.

Generic Table Component

// Generic table with column definitions
interface Column<T> {
  key: keyof T;
  header: string;
  render?: (value: T[keyof T], row: T) => string;
}

interface TableProps<T> {
  data: T[];
  columns: Column<T>[];
}

function Table<T extends Record<string, any>>(props: TableProps<T>): string {
  const headers = props.columns.map(col => col.header).join(' | ');
  const separator = props.columns.map(() => '---').join(' | ');
  const rows = props.data.map(row =>
    props.columns.map(col => {
      const value = row[col.key];
      return col.render ? col.render(value, row) : String(value);
    }).join(' | ')
  );
  return [headers, separator, ...rows].join('\n');
}

interface Product {
  name: string;
  price: number;
  inStock: boolean;
}

const table = Table<Product>({
  data: [
    { name: 'Laptop', price: 999, inStock: true },
    { name: 'Phone', price: 699, inStock: false }
  ],
  columns: [
    { key: 'name', header: 'Product' },
    { key: 'price', header: 'Price', render: (v) => `${v}` },
    { key: 'inStock', header: 'Available', render: (v) => v ? 'Yes' : 'No' }
  ]
});

console.log(table);

The Column interface uses 'keyof T' to ensure column keys correspond to actual properties of the data type. Custom render functions receive the correctly typed value.

Key points

Concepts covered

generic components, constrained generic props, generic lists, generic selects, type inference in JSX