Generics in React
Recipe
Build reusable, type-safe React components and hooks using TypeScript generics. Create components that work with any data type while preserving full type information for consumers.
Working Example
// Generic list component
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
};
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage - TypeScript infers T as User
type User = { id: string; name: string; email: string };
const users: User[] = [
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
];
<List
items={users}
keyExtractor={(user) => user.id} // user is typed as User
renderItem={(user) => <span>{user.name}</span>} // user is typed as User
/>// Generic custom hook
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue] as const;
}
// Usage - T is inferred as { theme: string; fontSize: number }
const [settings, setSettings] = useLocalStorage("settings", {
theme: "dark",
fontSize: 14,
});Deep Dive
How It Works
- Generic components use a type parameter (e.g.,
<T>) that acts as a placeholder. When the component is used, TypeScript infersTfrom the props you pass. - The syntax
function List<T>({ items }: ListProps<T>)declares a generic function component. The<T>must come before the parameter list. - TypeScript infers the generic from the first prop that uses it. In
<List items={users} ...>, it seesitemsisUser[], soTbecomesUser. - Generic hooks follow the same pattern as generic functions. The type parameter flows through the return type, giving callers full type inference.
Variations
Constrained generics (requiring a shape):
type HasId = { id: string };
type TableProps<T extends HasId> = {
data: T[];
columns: (keyof T)[];
};
function Table<T extends HasId>({ data, columns }: TableProps<T>) {
return (
<table>
<thead>
<tr>{columns.map((col) => <th key={String(col)}>{String(col)}</th>)}</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{columns.map((col) => <td key={String(col)}>{String(row[col])}</td>)}
</tr>
))}
</tbody>
</table>
);
}
// Usage - T must have an id field
<Table data={users} columns={["name", "email"]} />Generic select component:
type SelectProps<T> = {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
};
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selected = options.find((opt) => getValue(opt) === e.target.value);
if (selected) onChange(selected);
}}
>
{options.map((opt) => (
<option key={getValue(opt)} value={getValue(opt)}>
{getLabel(opt)}
</option>
))}
</select>
);
}Multiple type parameters:
function usePairedState<A, B>(initialA: A, initialB: B) {
const [a, setA] = useState<A>(initialA);
const [b, setB] = useState<B>(initialB);
return { a, setA, b, setB };
}TypeScript Notes
- In
.tsxfiles,<T>in arrow functions can be confused with JSX. Use<T,>(trailing comma) or<T extends unknown>to disambiguate:const List = <T,>({ items }: ListProps<T>) => .... extendsin generics acts as a constraint, not inheritance.T extends HasIdmeans "T must have at least the properties of HasId."keyof Tgives you a union of all property names ofT. Combined with generics, it enables type-safe property access.
Gotchas
- You cannot use
React.FCwith generic components. Write them as regular function declarations:function List<T>(props: ListProps<T>). - Arrow function generics in
.tsxfiles need the trailing comma trick:<T,>instead of<T>. Without it, the parser thinks<T>is a JSX tag. - Over-constraining generics makes components less reusable. Only constrain when you actually access specific properties.
- Generic inference can fail with complex prop combinations. In those cases, provide the type explicitly:
<List<User> items={users} ...>.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Generic components | Full type inference, reusable | More complex signatures |
| Union prop types | Simpler to read | Must enumerate all supported types |
any props | Maximum flexibility | No type safety |
| Render props with generics | Composable, type-safe | Verbose nesting |
| HOC with generics | Reusable logic wrapper | Complex type forwarding |
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Generic SSE streaming hook for multiple tool types
// File: src/hooks/use-builder-generator.ts
export function useBuilderGenerator<
TSections = Record<string, unknown>,
>(options: UseBuilderGeneratorOptions) {
const { toolId, toolSlug, maxGenerations = 3, onSectionReceived } = options;
const [streamState, setStreamState] = useState<
BuilderStreamState<TSections>
>(INITIAL_STREAM_STATE as BuilderStreamState<TSections>);
const abortControllerRef = useRef<AbortController | null>(null);
const handleSseEvent = useCallback(
(event: string, data: string) => {
try {
if (event === 'error') {
setStreamState((prev) => ({ ...prev, error: JSON.parse(data).error || 'Generation failed.' }));
return;
}
if (event === 'done') {
setStreamState((prev) => ({ ...prev, streamingSection: null }));
return;
}
// Tool-specific section parsing via callback injection
const parsed = onSectionReceived(event, data);
if (parsed) {
setStreamState((prev) => ({
...prev,
sections: { ...prev.sections, ...parsed } as TSections,
streamingSection: event,
}));
}
} catch { /* ignore individual event parse errors */ }
},
[onSectionReceived]
);
const generate = useCallback(async (inputs: Record<string, unknown>, model: string) => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setStreamState({ ...(INITIAL_STREAM_STATE as BuilderStreamState<TSections>), isStreaming: true });
const csrfToken = await getCsrfToken();
const response = await fetch(`/api/tools/${toolSlug}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
signal: controller.signal,
body: JSON.stringify({ inputs, model }),
});
// ... SSE stream parsing with handleSseEvent
}, [toolId, toolSlug, handleSseEvent]);
const canGenerate = !streamState.isStreaming && streamState.generationCount < maxGenerations;
return { streamState, generate, abort: () => abortControllerRef.current?.abort(), canGenerate };
}What this demonstrates in production:
TSectionsgeneric parameter lets each tool define its own section shape while sharing the streaming logic- The
onSectionReceivedcallback is the injection point. Each tool provides its own parser, but the streaming, state management, abort handling, and CSRF logic are shared BuilderStreamState<TSections>carries the generic through the state type sostreamState.sectionsis properly typed per toolabortControllerRefenables cancellation of in-flight requests. Previous requests are aborted when a new generation startsmaxGenerationslimits how many times a user can generate per session (cost control)- One hook implementation serves Network Builder, Object Storage Builder, SQL Database Builder, and NoSQL Builder. Only the section parsing differs
FAQs
How does TypeScript infer the generic type parameter in a component?
- TypeScript infers
Tfrom the first prop that uses the type parameter. - In
<List items={users}>, it seesitemsisUser[], soTbecomesUser. - All other props using
Tare then typed asUserautomatically.
Gotcha: Why can't you use React.FC with generic components?
React.FC<Props>does not support a type parameter on the component itself.- You must use a regular function declaration:
function List<T>(props: ListProps<T>). - This is a fundamental limitation of the
React.FCtype signature.
What is the trailing comma trick for arrow function generics in .tsx files?
// Without trailing comma: parser thinks <T> is a JSX tag
// const List = <T>(props: ListProps<T>) => ... // ERROR
// With trailing comma: disambiguates from JSX
const List = <T,>(props: ListProps<T>) => { ... };
// Alternative: use extends
const List = <T extends unknown>(props: ListProps<T>) => { ... };What does extends mean in a generic constraint?
- It acts as a constraint, not inheritance.
T extends HasIdmeans "T must have at least the properties of HasId." - You can then safely access
HasIdproperties on values of typeT. - Over-constraining makes components less reusable.
How do generic custom hooks work?
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
});
return [value, setValue] as const;
}- The type parameter
Tflows through the return type. - Callers get full type inference from the initial value.
What does keyof T do when combined with generics?
keyof Tgives a union of all property names ofT.- Combined with generics, it enables type-safe property access (e.g., column names for a table).
- Example:
columns: (keyof T)[]restricts column values to actual properties ofT.
When should you provide the generic type explicitly vs relying on inference?
- Rely on inference for most cases -- it reduces boilerplate.
- Provide it explicitly when inference fails with complex prop combinations:
<List<User> items={users} ...>. - Also useful when the initial value does not capture the full type (e.g., empty arrays).
Gotcha: What happens if you over-constrain a generic?
- The component becomes less reusable because fewer types satisfy the constraint.
- Only constrain when you actually access specific properties inside the component.
- A
<T>with no constraint accepts any type, maximizing flexibility.
Can you have multiple type parameters in a generic component or hook?
function usePairedState<A, B>(initialA: A, initialB: B) {
const [a, setA] = useState<A>(initialA);
const [b, setB] = useState<B>(initialB);
return { a, setA, b, setB };
}- Yes. Each type parameter is inferred independently from the corresponding argument.
How does as const help with generic hook return types?
- Without
as const, the return type of[value, setValue]is inferred as(T | SetStateAction<T>)[]. - With
as const, it becomes a readonly tuple[T, Dispatch<SetStateAction<T>>]. - This preserves the position-based types for destructuring.