abstract decorative image with a squiggly line element

TypeScript Generics in React Components

In a React application, it is highly probable that we, as adjoe’s frontend developers, have some components that are heavily used across all the sections of the app – our core components. However, as the application grows, interaction with such components will become complex. Especially, if they are not designed to be flexible enough to support different use cases. 

Fortunately, TypeScript Generics – being the powerful tool that it is – can help us to alleviate such pain.

Case Study: A List Component

Imagine we need to create a component that renders a list of options. Initially, we could start with something general enough to support different option data types, like this:

interface ListOption {
  id: string;
  label: string;
}

interface ListProps {
  options: ListOption[];
  onOptionClick: (option: ListOption) => void;
}

const List = ({ options, onOptionClick }: ListProps) => {
  return (
    <ul>
      {options.map((option) => (
        <li
          key={option.id}
          onClick={() => onOptionClick(option)}
        >
          {option.label}
        </li>
      ))}
    </ul>
  )
};

And we could use the component to render a list of any of our entities, such as User or App:

interface App {
  id: string;
  title: string;
  platform: 'ios' | 'android';
}

const apps: App[] = [
  { title: 'Cat Escape', platform: 'android', id: 'gg.sunday.catescape' },
  { title: 'Spinner Merge', platform: 'ios', id: 'id6443864930' },
];

const appOptions: ListOption[] = apps.map(app => ({ id: app.id, label: app.title }));

interface User {
  id: string;
  name: string;
  active: boolean;
}

const users: User[] = [
  { id: '1', name: 'Isabel', active: true },
  { id: '2', name: 'Andres', active: true },
];

const userOptions: ListOption[] = users.map(user => ({ id: user.id, label: user.name }));

const DashboardComponent = () => {
  ...
  return (
    <>
      <List options={appOptions} onOptionClick={handleAppClick} />
      <List options={userOptions} onOptionClick={handleUserClick} />
    </>
  );
}

But, as you might notice, we already at this point have a situation on our hands that isn’t ideal: Just look at user interaction onClick. If we need to perform any action with the original entity (e.g. User), we need to iterate the source array to retrieve the original entity before performing the action:

const handleAppClick = (appOption: ListOption) => {
  const appIdx = apps.findIndex(app => app.id === appOption.id);
  if (appIdx !== -1) {
	const app = apps[appIdx];
	executeActionWithApp(app);
  }
};
const executeActionWithApp = (app: App) => {...}

After some List use cases in different parts of the app, the amount of boilerplate code we have to write only to support integration with the consumer components will become rather annoying. The reason for this extra logic is that our List component only knows about ListOption, and it’s not able to support a more generic type. But we can improve this by using TypeScript Generics.

Using TypeScript Generics in the List Component

There are a couple of ways to solve this problem by using Generics. One of these is to add an additional field to the ListOption interface, where we can place the original data entity. Instead of choosing a fixed data type for this field (such as any or User), we use a generic type (in this instance, called T) that is passed to the interface as a parameter:

interface ListOption<T> {
  id: string;
  label: string;
  data: T;
}

See how the type T is not only present for the data field (data: T) but also for the interface definition interface ListOption<T>. This means that the type T is a “parameter” for the ListOption interface and will therefore be defined later by the interface’s consumer. 

Let’s update our userOptions and appOptions to adopt the changes:

const appOptions: ListOption<App>[] = apps.map(app => (
  { id: app.id, label: app.title, data: app })
);
const userOptions: ListOption<User>[] = users.map(user => (
  { id: user.id, label: user.name, data: user })
);

Our next step is to update the ListProps interface to support the new generic type:

interface ListProps<T> {
  options: ListOption<T>[];
  onOptionClick: (option: ListOption<T>) => void;
}

Note how ListProps now needs to have a generic type, too (also called T), so it can be forwarded to the ListOption interface. We have also set a constraint between options and onOptionClick, since both are referencing the same type T.

It means that if an instance of the List component has options of type ListOption<User>, onOptionClick must be used with ListOption<User>, too – not with ListOption<App>, for example, or the compiler will comply.

Before we use the List component with generics, the component definition has to be tweaked a bit:

const List = <T,>({ options, onOptionClick }: ListProps<T>) => {...}

Here, we’re adding the generic type to the arrow function by preceding it with <T,>. The comma before > is only needed in files using JSX and when using only one generic type.

The last step is to update the click-handlers:

const DashboardComponent = () => {
  ...
  const handleAppClick = (appOption: ListOption<App>) => {
    executeActionWithApp(appOption.data);
  };

  const handleUserClick = (userOption: ListOption<User>) => {
    executeActionWithUser(userOption.data);
  }

  return (
    <>
      <List options={appOptions} onOptionClick={handleAppClick} />
      <List options={userOptions} onOptionClick={handleUserClick} />
    </>
  );
}

What Are the Benefits of Using React Generic Components?

As demonstrated with the simple List component use case, we, as developers, could benefit a lot from introducing TypeScript Generics to some of our React generic components. We would achieve improvements such as:

  • Components can become more flexible regarding the types they support via props.
  • We can still achieve strong typing for every type we decide to use from the consumer side (like User).
  • We can remove some lines of code needed to support the non-generic components version from the project.

And this is only the beginning. More advanced use cases could take our React generic components to the next level. These include using Generic Constraints, Type Parameters in Generic Constraints, and more.

Drive solutions, thrive in our team

See vacancies