Type something to search...
Generic Components in React with TypeScript

Generic Components in React with TypeScript

Sooner or later you build a component that works with "some kind of item": a list, a dropdown, a table, an autocomplete. The first version usually types items as any, or as a specific type like User, and then gets copied when you need the same thing for products. Neither is great. any throws away type safety at exactly the point where callbacks hand data back to you, and copying means fixing every bug twice.

Generic components solve this. A generic component is a function component with a type parameter, so the type of the items you pass in flows through to render callbacks, onSelect handlers, and keys. You write the component once, and TypeScript checks every usage against the actual data.

In this post I'll build a generic List, a generic Select, and a generic DataTable with typed columns. Along the way I'll cover constraints, inference, the .tsx arrow function quirk, keyof tricks for column keys, and when a generic is overkill.

Why Not Just Use any?

Here's a list component that takes any:

import type { ReactNode } from "react";

type ListProps = {
  items: any[];
  renderItem: (item: any) => ReactNode;
};

function List({ items, renderItem }: ListProps) {
  return <ul>{items.map((item, i) => <li key={i}>{renderItem(item)}</li>)}</ul>;
}

// No error, crashes at runtime: User has no "title"
<List items={users} renderItem={(user) => user.title.toUpperCase()} />;

Inside renderItem, user is any. You get no autocomplete, and the typo on title compiles fine. The component "works" with every type by giving up on checking any of them.

Your First Generic Component

Add a type parameter T and use it in the props:

import type { ReactNode } from "react";

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => ReactNode;
  getKey: (item: T) => string | number;
};

export function List<T>({ items, renderItem, getKey }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

Now use it:

type User = { id: number; name: string; email: string };

const users: User[] = [
  { id: 1, name: "Ada", email: "ada@example.com" },
  { id: 2, name: "Linus", email: "linus@example.com" },
];

export function UserList() {
  return (
    <List
      items={users}
      getKey={(user) => user.id}
      renderItem={(user) => (
        <span>
          {user.name} ({user.email})
        </span>
      )}
    />
  );
}

You never wrote List<User>. TypeScript infers T from the items prop, then uses it to type the user parameter in both callbacks. Hover over user and you'll see User. Write user.title and you get an error.

The same component works with products, strings, or anything else, and each usage is checked independently.

The Arrow Function Syntax in .tsx Files

If you prefer arrow functions, there's a parsing quirk. In a .tsx file, const List = <T>(props: ListProps<T>) => ... is ambiguous, because <T> looks like the start of a JSX tag. Add a trailing comma or a constraint to disambiguate:

export const List = <T,>({ items, renderItem, getKey }: ListProps<T>) => {
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
};

<T extends unknown> also works. Function declarations don't have this problem, which is one reason I use them for generic components.

Adding Constraints

Sometimes a component needs to know something about T. If your list always uses an id field as the key, you can require it with a constraint:

type ListProps<T extends { id: string | number }> = {
  items: T[];
  renderItem: (item: T) => ReactNode;
};

export function List<T extends { id: string | number }>({ items, renderItem }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

T extends { id: string | number } means "any type, as long as it has an id". Inside the component you can read item.id, and callers passing items without an id get a compile error. Callers still get their full type back in renderItem, not just { id }.

Constraints are a trade-off. They make the component simpler to use but less flexible. A getKey callback works with anything, while an id constraint forces callers to reshape data that uses slug or uuid. For a shared component library, prefer the callback.

A Generic Select Component

Native select elements only deal with strings. In real apps you want to select objects: a user, a country, a plan. A generic select maps objects to option labels and values, then hands the selected object back with its type intact:

import { useId } from "react";

type SelectProps<T> = {
  label: string;
  options: readonly T[];
  value: T | null;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
  getValue: (option: T) => string;
  placeholder?: string;
};

export function Select<T>({
  label,
  options,
  value,
  onChange,
  getLabel,
  getValue,
  placeholder = "Choose...",
}: SelectProps<T>) {
  const id = useId();
  const selectedValue = value === null ? "" : getValue(value);

  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <select
        id={id}
        value={selectedValue}
        onChange={(e) => {
          const next = options.find((o) => getValue(o) === e.target.value);
          if (next !== undefined) onChange(next);
        }}
      >
        <option value="" disabled>
          {placeholder}
        </option>
        {options.map((option) => (
          <option key={getValue(option)} value={getValue(option)}>
            {getLabel(option)}
          </option>
        ))}
      </select>
    </div>
  );
}

Using it with a list of countries:

import { useState } from "react";

type Country = { code: string; name: string; currency: string };

const countries: Country[] = [
  { code: "BD", name: "Bangladesh", currency: "BDT" },
  { code: "DE", name: "Germany", currency: "EUR" },
  { code: "JP", name: "Japan", currency: "JPY" },
];

export function CheckoutCountry() {
  const [country, setCountry] = useState<Country | null>(null);

  return (
    <>
      <Select
        label="Country"
        options={countries}
        value={country}
        onChange={setCountry}
        getLabel={(c) => c.name}
        getValue={(c) => c.code}
      />
      {country && <p>Prices will be shown in {country.currency}.</p>}
    </>
  );
}

onChange={setCountry} type-checks because T is inferred as Country, and setCountry accepts a Country. If you passed a setter for a different type, TypeScript would complain. The readonly T[] in the props also means you can pass arrays declared with as const.

Inference From Multiple Props

When T appears in several props, TypeScript infers it from all of them and looks for a type that satisfies every position. Usually that's what you want. Occasionally inference picks something too wide or too narrow. For example, with value={null} and an empty options array, there's nothing to infer from, and T becomes unknown. In that case, pass the type explicitly:

<Select<Country>
  label="Country"
  options={[]}
  value={null}
  onChange={(c) => console.log(c.currency)}
  getLabel={(c) => c.name}
  getValue={(c) => c.code}
/>

The Select<Country> syntax works in JSX because TypeScript supports explicit type arguments on JSX elements.

A Generic Data Table with Typed Columns

Tables are where generics pay off most. You want column definitions that only accept keys that exist on the row type, and custom cell renderers that receive correctly typed values.

Start with the column type. keyof T gives you a union of the row's property names:

import type { ReactNode } from "react";

type Column<T> = {
  [K in keyof T]-?: {
    key: K;
    header: string;
    render?: (value: T[K], row: T) => ReactNode;
    align?: "left" | "right";
  };
}[keyof T];

This looks dense, so let's unpack it. The mapped type builds one column shape per key K. For a key of "price", render receives T["price"], which is a number. For "name", it receives a string. The trailing [keyof T] turns the mapped object into a union of those shapes. The result is that key and render are linked: you can't declare a column for "price" with a render function that expects a string.

Now the table:

type DataTableProps<T> = {
  rows: T[];
  columns: Column<T>[];
  getRowKey: (row: T) => string | number;
  emptyMessage?: string;
};

export function DataTable<T>({
  rows,
  columns,
  getRowKey,
  emptyMessage = "No data",
}: DataTableProps<T>) {
  if (rows.length === 0) {
    return <p className="table-empty">{emptyMessage}</p>;
  }

  return (
    <table>
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={String(col.key)} style={{ textAlign: col.align ?? "left" }}>
              {col.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <tr key={getRowKey(row)}>
            {columns.map((col) => (
              <td key={String(col.key)} style={{ textAlign: col.align ?? "left" }}>
                {renderCell(col, row)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function renderCell<T>(col: Column<T>, row: T): ReactNode {
  const value = row[col.key];
  if (col.render) {
    return (col.render as (v: typeof value, r: T) => ReactNode)(value, row);
  }
  return value == null ? "" : String(value);
}

The single cast in renderCell is there because TypeScript can't correlate the union of column shapes with the indexed value inside a generic function. That's a known limitation, and keeping the cast inside one small helper means the public API stays fully checked.

Here's the payoff, at the call site:

type Product = {
  sku: string;
  name: string;
  price: number;
  stock: number;
  updatedAt: Date;
};

const products: Product[] = [
  { sku: "MUG-01", name: "Coffee mug", price: 12.5, stock: 40, updatedAt: new Date("2026-09-01") },
  { sku: "TEE-02", name: "T-shirt", price: 24, stock: 0, updatedAt: new Date("2026-09-14") },
];

export function ProductTable() {
  return (
    <DataTable
      rows={products}
      getRowKey={(p) => p.sku}
      columns={[
        { key: "name", header: "Product" },
        {
          key: "price",
          header: "Price",
          align: "right",
          render: (price) => `$${price.toFixed(2)}`,
        },
        {
          key: "stock",
          header: "Stock",
          align: "right",
          render: (stock, row) => (stock === 0 ? <em>Out of stock ({row.sku})</em> : stock),
        },
        {
          key: "updatedAt",
          header: "Updated",
          render: (date) => date.toLocaleDateString(),
        },
      ]}
    />
  );
}

Every render gets the right value type without a single annotation. price.toFixed(2) is allowed because price is a number. date.toLocaleDateString() works because updatedAt is a Date. Try { key: "cost", header: "Cost" } and TypeScript rejects it because "cost" isn't a key of Product.

If you're rendering thousands of rows, combine this with virtualizing long lists with TanStack Virtual so only visible rows hit the DOM.

Generic Hooks Pair Well with Generic Components

Generic components often need generic state. A useSelection hook that tracks selected rows in the table above can be generic too:

import { useCallback, useState } from "react";

export function useSelection<T, K extends string | number>(getKey: (item: T) => K) {
  const [selected, setSelected] = useState<Set<K>>(() => new Set());

  const toggle = useCallback(
    (item: T) => {
      const key = getKey(item);
      setSelected((prev) => {
        const next = new Set(prev);
        if (next.has(key)) next.delete(key);
        else next.add(key);
        return next;
      });
    },
    [getKey],
  );

  const isSelected = useCallback((item: T) => selected.has(getKey(item)), [selected, getKey]);

  return { selected, toggle, isSelected };
}

Calling useSelection((p: Product) => p.sku) gives you a hook where toggle only accepts a Product and selected is a Set<string>. Annotating the callback parameter is what lets TypeScript infer T here, since there's no items argument to infer it from.

When a Generic Is Overkill

Generics add complexity to a component's signature, and that cost is worth it only when the component really is used with different types. Some signs you don't need one:

  • The component is only ever used with one type. Type it with that type. You can generalize later.
  • The component never hands items back. A Badge that renders label: string doesn't need T.
  • A union is enough. If the item is always a User or a Team, a union type with narrowing can be clearer than a type parameter.

Common Mistakes With Generic Components

  • Forgetting the trailing comma in arrow functions. <T>(props) => ... in a .tsx file is parsed as JSX. Write <T,> or use a function declaration.
  • Wrapping generic components in memo and losing the generic. memo(List) returns a component whose props are no longer generic. Cast the result back, for example memo(List) as typeof List, or skip memo and let the React Compiler handle memoization.
  • Constraining too early. T extends { id: string } feels convenient but forces every caller's data into one shape. A key getter is more flexible.
  • Using index as the key. A generic list doesn't know your data, so require a getKey callback instead of falling back to the array index.
  • Annotating callback parameters at every call site. If you find yourself writing (user: User) => everywhere, inference isn't working. Usually a prop that should mention T uses a concrete type instead.

Frequently Asked Questions (FAQ) About Generic Components in React

It's a function component with a TypeScript type parameter, such as function List<T>(props: ListProps<T>). The type parameter is inferred from the props you pass, so callbacks and render functions receive the correct item type without manual annotations.

Yes. TypeScript supports explicit type arguments on JSX elements, like Select<Country> before the props. You only need this when inference has nothing to work with, such as an empty options array and a null value.

In .tsx files, a bare <T> before parentheses looks like an opening JSX tag. Write <T,> with a trailing comma, or <T extends unknown>, or switch to a regular function declaration.

memo returns a non-generic component type, so T gets lost. The common workaround is a cast like const MemoList = memo(List) as typeof List. If you use the React Compiler, you often don't need memo at all.

No. Generics exist only at compile time and are erased from the JavaScript output. At runtime, a generic component is a normal function component with no extra cost.

Use keyof T. Plain strings let typos through and lose the link between a column and its value type. With keyof T and a mapped type, each column's render function receives the exact type of that property.

Conclusion

Generic components let you write one implementation that stays type-safe for every data shape it's used with. Put a type parameter on the function, use it in every prop that touches items, and let TypeScript infer it from the data. Add constraints only when the component truly needs them, prefer key-getter callbacks over assumed fields, and use keyof with mapped types when props need to relate to specific properties.

A good next step is to take one component in your codebase that's typed with any, or duplicated for two data types, and convert it to a generic. If you're still getting comfortable with the basics, start with typing props, state, and events in React, then come back to generics once inference feels familiar.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading