Right, so you’ve built a few <Button> and <Card> components. They work. They’re type-safe. You feel good. Then your PM walks over and says, “Great, now can we have a <Select> where the options can be strings, numbers, or maybe even entire user objects? And a <DataTable> that can take any sort of row shape? And, oh, a function that can log any value but also return it?”

This is where you graduate from just using TypeScript to wielding it. You need generic components. The concept is simple: it’s a component that defers specifying its exact data types until you use it. Think of it like a function argument, but for types. You’re making a promise: “I will work with whatever type you give me later, and I’ll make sure you use it correctly.”

The Core Syntax: It’s Just a Type Parameter

A generic component is just a regular component that accepts a type parameter (or several). You declare it right after the component name. The most common convention is to use a single letter like T, but TItem or TValue are often clearer.

Let’s start with a classic: a simple list component that renders an array of anything.

// T is our type parameter. It's a placeholder for the real type.
interface GenericListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
}

// Notice how we pass the type parameter `T` to the `props` type
function GenericList<T>({ items, renderItem }: GenericListProps<T>) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

And here’s how you use it. The type parameter is specified in angle brackets right after the component name. The beauty is that TypeScript can almost always infer it, so you can often leave it out.

// Usage with explicit type (usually unnecessary)
<GenericList<number>
  items={[1, 2, 3]}
  renderItem={(item) => <span>The number is {item}</span>} // `item` is correctly typed as `number`
/>

// Usage with type inference (what you'll do 99% of the time)
const users = [{ name: 'Alice' }, { name: 'Bob' }];

<GenericList
  items={users}
  renderItem={(user) => <span>{user.name}</span>} // TypeScript knows `user` is `{ name: string }`
/>;

The moment you pass the items array, TypeScript says, “Ah, items is of type User[], so T must be User.” It then applies that type to the renderItem function argument, giving you perfect type safety and autocomplete. It’s genuinely satisfying.

Constraining Your Generics (Because Chaos Isn’t Reusable)

Let’s say you’re creating a getId function. The problem? Not every type T has an id property. If you try to access item.id inside GenericList, TypeScript will rightly throw an error. This is where constraints come in. You use the extends keyword to tell TypeScript, “T can be any type, but it must at least have this shape.”

interface Identifiable {
  id: string | number;
}

// Now, T isn't "any" type. It's "any type that has at least an `id` property."
function IdentifiableList<T extends Identifiable>({ items, renderItem }: GenericListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        // Now we can safely use `item.id` for the key!
        <li key={item.id}>{renderItem(item)}</li> 
      ))}
    </ul>
  );
}

// This will work beautifully:
const identifiableUsers = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
<IdentifiableList
  items={identifiableUsers}
  renderItem={(user) => <span>{user.name}</span>} // user is typed as T, which has `id` and `name`
/>;

// This will fail at compile time, which is exactly what we want:
const regularUsers = [{ name: 'Alice' }, { name: 'Bob' }];
<IdentifiableList
  items={regularUsers} // Error: Property 'id' is missing on type '{ name: string; }'
/>;

Default Type Parameters: For a Polite Default Behavior

Sometimes, you want a sensible default. A <Select> component might commonly be used with string options, but you want to leave the door open for more complex objects. This is what default type parameters are for.

interface SelectOption<T = string> {
  label: string;
  value: T;
}

interface SelectProps<T = string> {
  options: SelectOption<T>[];
  value: T | null;
  onChange: (value: T | null) => void;
}

// By default, T is string. No need to specify it for a simple select.
function Select<T = string>({ options, value, onChange }: SelectProps<T>) {
  // ... implementation
}

// Usage with default type (string)
<Select
  options={[{ label: 'Alice', value: 'alice' }, { label: 'Bob', value: 'bob' }]}
  value="alice"
  onChange={(newValue) => { /* newValue is string | null */ }}
/>;

// Usage with a custom type (User)
interface User { id: number; name: string; }
<Select<User>
  options={users.map(u => ({ label: u.name, value: u }))} // value is the entire user object
  value={selectedUser}
  onChange={(newValue) => { /* newValue is User | null. We have full type safety! */ }}
/>

The One Pitfall: JSX Ambiguity

Here’s a classic head-scratcher. Let’s say you have a generic component that takes no props. How do you call it?

function GenericComponent<T>() {
  return <div>Hello</div>;
}

// This will likely cause a parsing error:
<GenericComponent<number> />; // The compiler thinks the `<number>` is a JSX tag!

The solution is either to provide a prop (even an empty object) or to use the extended syntax, which is a bit uglier but unambiguous:

// Solution 1: Provide a prop (the easiest way)
<GenericComponent<number> someProp={true} />

// Solution 2: The dot syntax (less common but good to know)
<GenericComponent<number>/>

It’s a quirk of JSX grammar, and while it’s annoying, it’s easily worked around. Just another day in the life. The power you get from generics—creating incredibly flexible, reusable, and yet perfectly type-safe components—is absolutely worth this minor syntactic hiccup. Now go make a component that can handle anything your PM dreams up. You’re equipped for it.