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
refto a generic component, the classicforwardReferases the type parameter. In React 19refis a regular prop, so you can keep generics simply by addingref?: React.Ref<HTMLSelectElement>to your props — noforwardRefwrapper 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.
| Situation | What to do |
|---|---|
| Data passed in as a prop/arg | Let inference resolve T — write nothing |
Initial value is null or [] | Specify explicitly, e.g. useLocalStorage<User[]>("u", []) |
| Constraint must be enforced | Add T extends Shape on the declaration |
| Tuple return from a hook | Use as const so order and types are preserved |
Best practices
- Declare the type parameter on the function (
function List<T>(...)), not viaReact.FC, which does not support generics cleanly. - In
.tsxfiles 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
extendsconstraint that expresses what the component truly relies on — no more, no less. - Return tuples from hooks with
as constto keep element types and ordering intact. - Name parameters meaningfully (
TItem,TValue) once you have more than one, instead ofT,U,V. - In React 19, treat
refas a normal prop to keep components generic withoutforwardRef.