Skip to main content

TypeScript Generics for React Components: Advanced Patterns

Generics are TypeScript's most powerful feature for writing reusable code. A generic component accepts a type parameter—a type that the caller provides—allowing the same component to work with different data types while maintaining full type safety. Once you master generics, you can build flexible, reusable React components that scale across your entire application.

What Are TypeScript Generics and Why Use Them in React?

A generic is a placeholder for a type that you specify when you use the component. Instead of writing separate components for lists of strings, lists of numbers, and lists of objects, you write one generic component that works with any type. Generics let you write <T> (the type parameter) once and use it many times:

// Without generics: separate components for each type
function UserList({ users }: { users: User[] }) {
return <ul>{users.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
}

function PostList({ posts }: { posts: Post[] }) {
return <ul>{posts.map((post) => <li key={post.id}>{post.title}</li>)}</ul>;
}

// With generics: one reusable component
interface ListProps<T> {
items: T[];
getKey: (item: T) => string | number;
render: (item: T) => React.ReactNode;
}

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

// Use the same component with different types
<List<User>
items={users}
getKey={(user) => user.id}
render={(user) => user.name}
/>

<List<Post>
items={posts}
getKey={(post) => post.id}
render={(post) => post.title}
/>

Generics eliminate code duplication and are the foundation of reusable, scalable component libraries.

How Do You Define a Generic Component?

Define a generic component by adding type parameters in angle brackets (<T>) after the component name. The T can then be used anywhere in the props, state, or return type:

// Generic component with one type parameter
interface ContainerProps<T> {
value: T;
onChange: (newValue: T) => void;
}

function Container<T>({ value, onChange }: ContainerProps<T>) {
return (
<div>
<p>Value: {JSON.stringify(value)}</p>
<button onClick={() => onChange(value)}>Update</button>
</div>
);
}

// Use with different types
const numberContainer = <Container<number> value={42} onChange={(n) => console.log(n)} />;
const stringContainer = <Container<string> value="hello" onChange={(s) => console.log(s)} />;
const objectContainer = <Container<{ name: string }> value={{ name: "Alice" }} onChange={(obj) => console.log(obj)} />;

TypeScript also infers type parameters from usage:

// TypeScript infers T = number from value={42}
<Container value={42} onChange={(n) => console.log(n)} />

// TypeScript infers T = string from value="hello"
<Container value="hello" onChange={(s) => console.log(s)} />

In most cases, you do not need to write <Container<number>> explicitly; TypeScript figures it out from the props you pass.

What Are Generic Constraints?

A generic constraint limits which types a type parameter can be. You use the extends keyword to declare a constraint. For example, you might have a component that only works with objects, not primitives:

// Constraint: T must be an object
interface WithIdProps<T extends { id: string | number }> {
item: T;
onSelect: (id: string | number) => void;
}

function WithId<T extends { id: string | number }>({ item, onSelect }: WithIdProps<T>) {
return (
<div onClick={() => onSelect(item.id)}>
{JSON.stringify(item)}
</div>
);
}

// Works: User has an id
const user: User = { id: 1, name: "Alice" };
<WithId item={user} onSelect={(id) => console.log(id)} />

// ERROR: number has no id property
<WithId item={42} onSelect={(id) => console.log(id)} />

Constraints make generics safer by ensuring only valid types are passed. Here are more examples:

// T must be a keyof some object type
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}

const user = { name: "Alice", age: 30 };
const name = getProperty(user, "name"); // Works
// getProperty(user, "invalid"); // ERROR: "invalid" is not a key of user

// T must be an array
function getFirst<T extends unknown[]>(arr: T) {
return arr[0];
}

const first = getFirst([1, 2, 3]); // Works
// getFirst(42); // ERROR: 42 is not an array

// T must be a React component
function withLogging<T extends React.ComponentType<any>>(Component: T) {
return (props: any) => {
console.log("Rendering", Component.name);
return <Component {...props} />;
};
}

Constraints are essential for catching mistakes early and making generics more predictable.

How Do You Use Multiple Type Parameters?

Many components need more than one type parameter. A classic example is a dictionary or mapping component that keys and values:

interface DictionaryProps<K, V> {
entries: Array<[K, V]>;
renderKey: (key: K) => React.ReactNode;
renderValue: (value: V) => React.ReactNode;
}

function Dictionary<K, V>({
entries,
renderKey,
renderValue,
}: DictionaryProps<K, V>) {
return (
<table>
<tbody>
{entries.map(([key, value]) => (
<tr key={String(key)}>
<td>{renderKey(key)}</td>
<td>{renderValue(value)}</td>
</tr>
))}
</tbody>
</table>
);
}

// Use with string keys and object values
interface User {
id: number;
name: string;
}

const userMap: Array<[string, User]> = [
["user-1", { id: 1, name: "Alice" }],
["user-2", { id: 2, name: "Bob" }],
];

<Dictionary<string, User>
entries={userMap}
renderKey={(key) => <strong>{key}</strong>}
renderValue={(user) => <span>{user.name}</span>}
/>

Multiple type parameters give you precise control over component behavior for complex use cases.

What Are Generic Defaults?

You can provide default types for type parameters, so callers do not always need to specify them:

// Default type parameter
interface TableProps<T = Record<string, unknown>> {
data: T[];
columns: Array<{
key: keyof T;
label: string;
}>;
}

function Table<T = Record<string, unknown>>({ data, columns }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => <th key={String(col.key)}>{col.label}</th>)}
</tr>
</thead>
<tbody>
{data.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => <td key={String(col.key)}>{row[col.key]}</td>)}
</tr>
))}
</tbody>
</table>
);
}

// Use without specifying the type (uses default)
<Table
data={[{ name: "Alice", age: 30 }]}
columns={[
{ key: "name", label: "Name" },
{ key: "age", label: "Age" },
]}
/>

// Or specify a specific type
interface User {
id: number;
name: string;
}

<Table<User>
data={users}
columns={[
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
]}
/>

Generic defaults make components easier to use without sacrificing type safety when more specific types are needed.

How Do You Use Generics with Hooks?

Custom hooks can also be generic. A generic hook is often useful for wrapping API calls or data transformations:

interface UseAsyncState<T> {
loading: boolean;
error: Error | null;
data: T | null;
}

// Generic hook that fetches data of any type
function useAsync<T>(
fetchFn: () => Promise<T>,
dependencies: unknown[]
): UseAsyncState<T> {
const [state, setState] = useState<UseAsyncState<T>>({
loading: true,
error: null,
data: null,
});

useEffect(() => {
fetchFn()
.then((data) => setState({ loading: false, error: null, data }))
.catch((error) => setState({ loading: false, error, data: null }));
}, dependencies);

return state;
}

// Use with different data types
interface User {
id: number;
name: string;
}

function UserProfile() {
const { data: user, loading, error } = useAsync<User>(
() => fetch("/api/user").then((r) => r.json()),
[]
);

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}

Generic hooks are powerful for building reusable data-fetching and state-management patterns.

Key Takeaways

  • Generics let you write one component that works with many types; use <T> to define a type parameter.
  • Constraints (extends) limit which types are allowed; use them to ensure type safety and prevent runtime errors.
  • Multiple type parameters (<K, V>) give you fine-grained control over component inputs and outputs.
  • Generic defaults make components easier to use when you do not need to specify the type explicitly.
  • Generics work with hooks, too; generic hooks are powerful for reusable data-fetching and transformation patterns.

Frequently Asked Questions

Do I need generics for every component?

No. Use generics when a component works with different data types in the same way (like a list, table, or form). Simple, single-purpose components do not need generics. Add generics when you find yourself writing duplicate code for different types.

How do I debug generic type errors?

TypeScript's error messages for generics can be cryptic. Try hovering over the component in your IDE to see the inferred types. You can also explicitly specify the type to narrow down where the mismatch is:

// Error is unclear
<List items={users} />

// Make it explicit to see the error clearly
<List<User> items={users} />

Can I have optional type parameters?

Yes, use default type parameters:

interface BoxProps<T = unknown> {
value: T;
}

function Box<T = unknown>({ value }: BoxProps<T>) {
return <div>{JSON.stringify(value)}</div>;
}

What is the difference between any and unknown in generics?

unknown is a safe alternative to any. It forces you to check the type before using it, whereas any skips type checking entirely. Use unknown in generics when you truly do not know the type.

Further Reading