ReactGeneric Components

Generic Components

A generic component is a component that works correctly with multiple data types while preserving full type safety. Instead of accepting any[] and losing all type information, a generic component uses a type parameter T that TypeScript resolves at the call site — giving you autocomplete, error detection, and precise return types for every usage.

The Problem Generics Solve

Imagine you are building a Select dropdown. You need it to work with strings, numbers, and custom objects. Without generics, you either write the same component three times or accept any, losing type safety:

TSX
// ✗ Too wide — value and onChange lose all type information
interface BadSelectProps {
  options: any[]
  value: any
  onChange: (v: any) => void
}

// ✓ Generic — T is resolved by the caller
interface SelectProps<T> {
  options: T[]
  value: T
  onChange: (value: T) => void
  getLabel: (option: T) => string
}
A Generic Select Component

Here is the complete typed Select component. TypeScript infers T from the options prop — no explicit type argument needed at the call site:

TSX
interface SelectProps<T> {
  options: T[]
  value: T | null
  onChange: (value: T) => void
  getLabel: (option: T) => string
  getKey: (option: T) => string | number
  placeholder?: string
}

function Select<T>({
  options,
  value,
  onChange,
  getLabel,
  getKey,
  placeholder = 'Select...',
}: SelectProps<T>) {
  return (
    <select
      value={value ? getKey(value).toString() : ''}
      onChange={(e) => {
        const selected = options.find(o => getKey(o).toString() === e.currentTarget.value)
        if (selected !== undefined) onChange(selected)
      }}
    >
      <option value="" disabled>{placeholder}</option>
      {options.map(option => (
        <option key={getKey(option)} value={getKey(option).toString()}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  )
}

// Usage with strings — T is inferred as string
const colors = ['Red', 'Green', 'Blue']
<Select
  options={colors}
  value={selectedColor}
  onChange={setSelectedColor}
  getLabel={c => c}
  getKey={c => c}
/>

// Usage with objects — T is inferred as { id: number; name: string }
interface Country {
  id: number
  name: string
  code: string
}

<Select<Country>
  options={countries}
  value={selectedCountry}
  onChange={setSelectedCountry}
  getLabel={c => c.name}
  getKey={c => c.id}
/>
A Generic List Component

A generic list component separates the rendering logic (your responsibility) from the iteration and key management (the component's responsibility):

TSX
import { ReactNode } from 'react'

interface ListProps<T> {
  items: T[]
  renderItem: (item: T, index: number) => ReactNode
  keyExtractor: (item: T) => string | number
  emptyMessage?: string
}

function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
  if (items.length === 0) {
    return <p className="empty">{emptyMessage}</p>
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  )
}

// With users — T = User, renderItem provides a User
interface User {
  id: string
  name: string
  avatar: string
}

<List<User>
  items={users}
  keyExtractor={u => u.id}
  renderItem={u => (
    <div>
      <img src={u.avatar} alt={u.name} />
      <span>{u.name}</span>
    </div>
  )}
  emptyMessage="No users found"
/>

// With plain strings — T is inferred as string
<List
  items={['Apple', 'Banana', 'Cherry']}
  keyExtractor={s => s}
  renderItem={s => <strong>{s}</strong>}
/>
Constrained Generics

Sometimes you need to guarantee that the type has certain properties. Use extends to constrain T:

TSX
// T must have at least an 'id' property of type string
interface TableProps<T extends { id: string }> {
  data: T[]
  columns: {
    key: keyof T
    header: string
    render?: (value: T[keyof T], row: T) => ReactNode
  }[]
}

function Table<T extends { id: string }>({ data, columns }: TableProps<T>) {
  return (
    <table>
      <thead>
        <tr>{columns.map(col => <th key={String(col.key)}>{col.header}</th>)}</tr>
      </thead>
      <tbody>
        {data.map(row => (
          <tr key={row.id}>  {/* row.id is safe because of the constraint */}
            {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 — Product must have 'id: string' to satisfy the constraint
interface Product {
  id: string
  name: string
  price: number
  inStock: boolean
}

<Table<Product>
  data={products}
  columns={[
    { key: 'name', header: 'Name' },
    { key: 'price', header: 'Price', render: v => `$${v}` },
    { key: 'inStock', header: 'Stock', render: v => v ? '✓' : '✗' },
  ]}
/>
Generic Custom Hooks

Generics are not limited to components — custom hooks benefit just as much:

TSX
import { useState, useCallback } from 'react'

// Generic hook for async data fetching
interface AsyncState<T> {
  data: T | null
  loading: boolean
  error: string | null
}

function useAsync<T>(fetcher: () => Promise<T>): AsyncState<T> & { refetch: () => void } {
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    loading: false,
    error: null,
  })

  const refetch = useCallback(async () => {
    setState(s => ({ ...s, loading: true, error: null }))
    try {
      const data = await fetcher()
      setState({ data, loading: false, error: null })
    } catch (e) {
      setState({ data: null, loading: false, error: String(e) })
    }
  }, [fetcher])

  return { ...state, refetch }
}

// T is inferred from the fetcher's return type
const { data, loading, error } = useAsync<User>(() => fetchUser(userId))
// data is User | null — fully typed
Generic with Default Type

You can provide a default type for a generic parameter, making the type argument optional at the call site:

TSX
// T defaults to string — callers that don't specify T get string behavior
interface InputProps<T = string> {
  value: T
  onChange: (value: T) => void
  parse?: (raw: string) => T
  format?: (value: T) => string
}

function Input<T = string>({
  value,
  onChange,
  parse = v => v as unknown as T,
  format = v => String(v),
}: InputProps<T>) {
  return (
    <input
      value={format(value)}
      onChange={(e) => onChange(parse(e.currentTarget.value))}
    />
  )
}

// No type argument — defaults to string
<Input value={name} onChange={setName} />

// Explicit type — parses input as number
<Input<number>
  value={count}
  onChange={setCount}
  parse={Number}
  format={n => n.toString()}
/>
The satisfies Operator

TypeScript 4.9 introduced the satisfies operator, which validates that a value matches a type without widening it. This is useful for type-safe configuration objects in React:

TSX
type RouteConfig = {
  path: string
  component: React.ComponentType
  exact?: boolean
}

// ✗ Without satisfies — routes is widened to RouteConfig[]
// You lose autocomplete on route-specific properties
const routes: RouteConfig[] = [
  { path: '/', component: Home },
  { path: '/about', component: About },
]

// ✓ With satisfies — each entry is still typed as its exact object shape
// TypeScript can see the literal path values for autocomplete
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
] satisfies RouteConfig[]

// TypeScript knows routes[0].path is '/' (literal) not just string
Note
Generic components look more complex than they are in practice. Start by writing a specific component (`StringList`, `UserList`), then notice the pattern, and extract the generic parameter. TypeScript will tell you where the type needs to flow.
  • Use function Component&lt;T&gt; syntax for generics — arrow functions need a trailing comma in .tsx: &lt;T,&gt;

  • Constrain with T extends SomeInterface when you need to access specific properties

  • TypeScript infers T from props at the call site — explicit type arguments are optional

  • Generic hooks follow the same pattern — the return type reflects T

  • satisfies validates without widening — ideal for config objects and lookup tables