Skip to content
React rc typescript 5 min read

Generic Components

A truly reusable component often does not care what data it renders — only how it renders it. A list, a table, a dropdown, or a typeahead works the same whether it holds users, products, or numbers. TypeScript generics let you write that component once and have it preserve the exact element type all the way through, so the consumer gets full type safety and autocomplete with zero casting. This page shows how to build generic components like <List<T>> and <Select<T>>, write generic custom hooks, apply constraints, and lean on inference so callers rarely have to spell out the type argument.

Why generics beat any

Without generics, a flexible component usually falls back to any or unknown, which throws away every type guarantee. A generic introduces a type parameter — a placeholder, conventionally T — that is filled in at the call site and then flows through props, callbacks, and return values. The compiler links them together: tell the component your data is User[], and it knows every render callback receives a User.

// Loses all type information — `item` is `any`
function BadList({ items, render }: { items: any[]; render: (item: any) => React.ReactNode }) {
  return <ul>{items.map(render)}</ul>;
}

Writing a generic component

You declare the type parameter on the function itself, immediately before the props. The props type is then defined generically over T. Because JSX uses angle brackets too, you must write the parameter list in a way the parser can distinguish from JSX — in a .tsx file, use <T,> (with a trailing comma) or add an explicit constraint like <T extends unknown>.

interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyFor: (item: T) => string | number;
}

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

Now T is inferred from the items prop, and every callback is fully typed:

interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: "Ada" },
  { id: 2, name: "Linus" },
];

function UserList() {
  return (
    <List
      items={users}
      keyFor={(u) => u.id}
      renderItem={(u) => <strong>{u.name}</strong>}
    />
  );
}

You never wrote <List<User>> — TypeScript inferred T = User from items, so u inside renderItem is a User and u.name autocompletes.

Constraints with extends

Sometimes the component needs to assume the data has a certain shape. A constraint (T extends ...) restricts which types are allowed while still keeping T specific. This lets a <Select<T>> require that each option has an id, without forcing you to pass a separate key function.

interface SelectProps<T extends { id: string | number }> {
  options: T[];
  value: T | null;
  onChange: (option: T) => void;
  getLabel: (option: T) => string;
}

function Select<T extends { id: string | number }>({
  options,
  value,
  onChange,
  getLabel,
}: SelectProps<T>) {
  return (
    <select
      value={value?.id ?? ""}
      onChange={(e) => {
        const next = options.find((o) => String(o.id) === e.target.value);
        if (next) onChange(next);
      }}
    >
      {options.map((option) => (
        <option key={option.id} value={option.id}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}

Passing an array whose elements lack an id is now a compile error, while the rest of each object’s shape stays fully accessible to getLabel.

When you need to forward a ref to a generic component, the classic forwardRef erases the type parameter. In React 19 ref is a regular prop, so you can keep generics simply by adding ref?: React.Ref<HTMLSelectElement> to your props — no forwardRef wrapper needed.

Generic custom hooks

Hooks benefit from the same technique. A useLocalStorage hook, for example, should return a value typed exactly like the default you pass in, and a setter that accepts the same type.

import { useState, useEffect } from "react";

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? (JSON.parse(stored) as T) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

Returning as const turns the array into a fixed-length tuple, so destructuring preserves the types in order:

const [theme, setTheme] = useLocalStorage("theme", "light"); // theme: string
const [count, setCount] = useLocalStorage("count", 0);        // count: number

Output:

theme is inferred as string, count as number — setTheme("dark") is fine,
setCount("dark") is a compile error.

Inference vs explicit type arguments

Most of the time inference does the work. Reach for an explicit type argument only when there is nothing to infer from — typically when a generic value starts out null or empty.

SituationWhat to do
Data passed in as a prop/argLet inference resolve T — write nothing
Initial value is null or []Specify explicitly, e.g. useLocalStorage<User[]>("u", [])
Constraint must be enforcedAdd T extends Shape on the declaration
Tuple return from a hookUse as const so order and types are preserved

Best practices

  • Declare the type parameter on the function (function List<T>(...)), not via React.FC, which does not support generics cleanly.
  • In .tsx files write <T,> or <T extends unknown> so the trailing comma disambiguates from JSX syntax.
  • Prefer inference; only annotate the type argument when no value can drive it (null or empty initial state).
  • Add the narrowest extends constraint that expresses what the component truly relies on — no more, no less.
  • Return tuples from hooks with as const to keep element types and ordering intact.
  • Name parameters meaningfully (TItem, TValue) once you have more than one, instead of T, U, V.
  • In React 19, treat ref as a normal prop to keep components generic without forwardRef.
Last updated June 14, 2026
Was this helpful?