TypeScript generics on React function components

08/11/2021

TL;DR: TIL that you can use TypeScript generics on React function components and then use such components as <Component<Type> foo="bar" />.

The context

So I was working on a React (TypeScript) project at work and for one of the features I was working on, I happened to create a component that displayed a list of selectable elements that it accepted as props, along with a callback function that was called once the selected element was validated by the user.

My component looked something like that (with, ofc, much simplification for clarity's sake):

interface Item {
  label: string;
  value: string;
}

interface Props {
  items: Item[];
  onValidation: (item: Item) => void;
}

function List({ items, onValidation }: Props) {
  const [selected, setSelected] = useState<Item>(items[0]);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item.value}>
            <button onClick={() => setSelected(item)}>{item.value}</button>
          </li>
        ))}
      </ul>

      <button onClick={() => onValidation(selected)}>Validate</button>
    </div>
  );
}

And I was using my component this way:

// ...
const items: Item[] = [
  { label: "Choice A", value: "a" },
  { label: "Choice B", value: "b" },
  { label: "Choice C", value: "c" },
];

const handleListValidation = (item: Item) => {
  console.log(`Yay, ${item.label} chosen !`);
};

return <List items={items} onValidation={handleListValidation} />;
// ...

The problem

As long as I worked on my feature, I had to use this List component several times around the codebase, giving it a slightly different kind of items every time. My List were using the only two Item fields it knew (label and value) so it did the trick, until I wanted to use some more of my item fields in the function I gave to onValidation prop.

// ...
interface ItemWithLink extends Item {
  redirectLink: string;
}

const items: ItemWithLink[] = [
  { label: "Choice A", value: "a", redirectLink: "..." },
  { label: "Choice B", value: "b", redirectLink: "..." },
  { label: "Choice C", value: "c", redirectLink: "..." },
];

const handleListValidation = (item: ItemWithLink) => {
  somehowRedirect(item.redirectLink);
};

return (
  <List
    // no problem here as `ItemWithLink` extends `Item`
    items={items}
    //❗ problem here, `handleListValidation` should accept an `Item`, not an `ItemWithLink`
    onValidation={handleListValidation}
  />
);
// ...

The solution

TS generics to the rescue ! Why not after all ? That's probably how I would have done it with regular functions, and React component are functions ! So that's how I went and tried:

// `List` now accepts items of types **extending** `Item`
interface Props<T extends Item> {
  items: T[];
  onValidation: (item: T) => void;
}

function List<T extends Item>({ items, onValidation }: Props<T>) {
  const [selected, setSelected] = useState<T>(items[0]);

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item.value}>
            <button onClick={() => setSelected(item)}>{item.value}</button>
          </li>
        ))}
      </ul>

      <button onClick={() => onValidation(selected)}>Validate</button>
    </div>
  );
}

With these changes, I could now use my List component with a given type using this (kinda weird) syntax:

const items: ItemWithLink[] = [
  { label: "Choice A", value: "a", redirectLink: "..." },
  { label: "Choice B", value: "b", redirectLink: "..." },
  { label: "Choice C", value: "c", redirectLink: "..." },
];

const handleListValidation = (item: ItemWithLink) => {
  somehowRedirect(item.redirectLink);
};

return (
  <List<ItemWithLink>
    // still no problem here
    items={items}
    // no more TS errors here !
    // `List` now knows that it handles items of type `ItemWithLink`
    onValidation={handleListValidation}
  />
);
// ...