//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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,
});<T>) that acts as a placeholder. When the component is used, TypeScript infers T from the props you pass.function List<T>({ items }: ListProps<T>) declares a generic function component. The <T> must come before the parameter list.<List items={users} ...>, it sees items is User[], so T becomes User.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 };
}.tsx files, <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>) => ....extends in generics acts as a constraint, not inheritance. T extends HasId means "T must have at least the properties of HasId."keyof T gives you a union of all property names of T. Combined with generics, it enables type-safe property access.React.FC with generic components. Write them as regular function declarations: function List<T>(props: ListProps<T>)..tsx files need the trailing comma trick: <T,> instead of <T>. Without it, the parser thinks <T> is a JSX tag.<List<User> items={users} ...>.| 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 |
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:
TSections generic parameter lets each tool define its own section shape while sharing the streaming logiconSectionReceived callback is the injection point. Each tool provides its own parser, but the streaming, state management, abort handling, and CSRF logic are sharedBuilderStreamState<TSections> carries the generic through the state type so streamState.sections is properly typed per toolabortControllerRef enables cancellation of in-flight requests. Previous requests are aborted when a new generation startsmaxGenerations limits how many times a user can generate per session (cost control)T from the first prop that uses the type parameter.<List items={users}>, it sees items is User[], so T becomes User.T are then typed as User automatically.React.FC<Props> does not support a type parameter on the component itself.function List<T>(props: ListProps<T>).React.FC type signature.// 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>) => { ... };T extends HasId means "T must have at least the properties of HasId."HasId properties on values of type T.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;
}T flows through the return type.keyof T gives a union of all property names of T.columns: (keyof T)[] restricts column values to actual properties of T.<List<User> items={users} ...>.<T> with no constraint accepts any type, maximizing flexibility.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 };
}as const, the return type of [value, setValue] is inferred as (T | SetStateAction<T>)[].as const, it becomes a readonly tuple [T, Dispatch<SetStateAction<T>>].Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥