Genéricos en React
Receta
Construye componentes y hooks de React reutilizables y type-safe usando genéricos de TypeScript. Crea componentes que funcionan con cualquier tipo de datos mientras preservas información de tipo completa para los consumidores.
Ejemplo Funcional
// Componente de lista genérico
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>
);
}
// Uso - TypeScript infiere T como 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 está tipado como User
renderItem={(user) => <span>{user.name}</span>} // user está tipado como User
/>// Hook personalizado genérico
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;
}
// Uso - T se infiere como { theme: string; fontSize: number }
const [settings, setSettings] = useLocalStorage("settings", {
theme: "dark",
fontSize: 14,
});Análisis Profundo
Cómo Funciona
- Los componentes genéricos usan un type parameter (ej.,
<T>) que actúa como un placeholder. Cuando se usa el componente, TypeScript infiereTdel props que pasas. - La sintaxis
function List<T>({ items }: ListProps<T>)declara una función componente genérica. El<T>debe venir antes de la lista de parámetros. - TypeScript infiere el genérico del primer prop que lo usa. En
<List items={users} ...>, ve queitemsesUser[], así queTse convierte enUser. - Los hooks genéricos siguen el mismo patrón que las funciones genéricas. El type parameter fluye a través del tipo de retorno, dando a los llamadores inferencia de tipo completa.
Variaciones
Genéricos restringidos (requiriendo una forma):
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>
);
}
// Uso - T debe tener un field id
<Table data={users} columns={["name", "email"]} />Componente genérico select:
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>
);
}Múltiples 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 };
}Notas de TypeScript
- En archivos
.tsx,<T>en arrow functions puede confundirse con JSX. Usa<T,>(coma al final) o<T extends unknown>para desambiguar:const List = <T,>({ items }: ListProps<T>) => .... extendsen genéricos actúa como una limitación, no herencia.T extends HasIdsignifica "T debe tener al menos las propiedades de HasId."keyof Tte da una unión de todos los nombres de propiedades deT. Combinado con genéricos, habilita acceso type-safe a propiedades.
Trampas
- No puedes usar
React.FCcon componentes genéricos. Escríbelos como declaraciones de función regulares:function List<T>(props: ListProps<T>). - Arrow function generics en archivos
.tsxnecesitan el truco de la coma al final:<T,>en lugar de<T>. Sin ello, el parser piensa que<T>es una etiqueta JSX. - Limitar excesivamente los genéricos hace componentes menos reutilizables. Solo limita cuando realmente accedes propiedades específicas.
- La inferencia de genéricos puede fallar con combinaciones de props complejas. En esos casos, proporciona el tipo explícitamente:
<List<User> items={users} ...>.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Componentes genéricos | Inferencia de tipo completa, reutilizable | Firmas más complejas |
| Tipos de prop unión | Más simple de leer | Debes enumerar todos los tipos soportados |
Props any | Flexibilidad máxima | Sin type safety |
| Render props con genéricos | Composable, type-safe | Nesting verboso |
| HOC con genéricos | Lógica wrapper reutilizable | Forwarding de tipo complejo |
Ejemplo del Mundo Real
Desde una aplicación SaaS Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo en producción: Hook genérico de SSE streaming para múltiples tipos de herramientas
// Archivo: 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;
}
// Parsing de sección específica de herramienta vía inyección de callback
const parsed = onSectionReceived(event, data);
if (parsed) {
setStreamState((prev) => ({
...prev,
sections: { ...prev.sections, ...parsed } as TSections,
streamingSection: event,
}));
}
} catch { /* ignora errores de parseo de eventos individuales */ }
},
[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 }),
});
// ... parseo de stream SSE con handleSseEvent
}, [toolId, toolSlug, handleSseEvent]);
const canGenerate = !streamState.isStreaming && streamState.generationCount < maxGenerations;
return { streamState, generate, abort: () => abortControllerRef.current?.abort(), canGenerate };
}Lo que esto demuestra en producción:
- El type parameter
TSectionsgenérico permite que cada herramienta defina su propia forma de sección mientras comparte la lógica de streaming - El callback
onSectionReceivedes el punto de inyección. Cada herramienta proporciona su propio parser, pero el streaming, manejo de estado, manejo de abort, y lógica CSRF son compartidos BuilderStreamState<TSections>lleva el genérico a través del tipo de estado para questreamState.sectionsesté tipado correctamente por herramientaabortControllerRefhabilita cancelación de requests en vuelo. Los requests previos se abortan cuando comienza una nueva generaciónmaxGenerationslimita cuántas veces un usuario puede generar por sesión (control de costos)- Una implementación de hook sirve Network Builder, Object Storage Builder, SQL Database Builder, y NoSQL Builder. Solo el parsing de sección difiere
FAQs
¿Cómo infiere TypeScript el type parameter genérico en un componente?
- TypeScript infiere
Tdel primer prop que usa el type parameter. - En
<List items={users}>, ve queitemsesUser[], así queTse convierte enUser. - Todos los otros props que usan
Tluego son tipados comoUserautomáticamente.
Trampa: ¿Por qué no puedes usar React.FC con componentes genéricos?
React.FC<Props>no soporta un type parameter en el componente mismo.- Debes usar una declaración de función regular:
function List<T>(props: ListProps<T>). - Esta es una limitación fundamental de la firma del tipo
React.FC.
¿Cuál es el truco de la coma al final para arrow function generics en archivos .tsx?
// Sin coma al final: parser piensa que <T> es una etiqueta JSX
// const List = <T>(props: ListProps<T>) => ... // ERROR
// Con coma al final: desambigua de JSX
const List = <T,>(props: ListProps<T>) => { ... };
// Alternativa: usa extends
const List = <T extends unknown>(props: ListProps<T>) => { ... };¿Qué significa extends en una limitación genérica?
- Actúa como una limitación, no herencia.
T extends HasIdsignifica "T debe tener al menos las propiedades de HasId." - Luego puedes acceder con seguridad a las propiedades de
HasIden valores de tipoT. - Limitar excesivamente hace componentes menos reutilizables.
¿Cómo funcionan los hooks personalizados genéricos?
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;
}- El type parameter
Tfluye a través del tipo de retorno. - Los llamadores obtienen inferencia de tipo completa del valor inicial.
¿Qué hace keyof T cuando se combina con genéricos?
keyof Tda una unión de todos los nombres de propiedades deT.- Combinado con genéricos, habilita acceso type-safe a propiedades (ej., nombres de columnas para una tabla).
- Ejemplo:
columns: (keyof T)[]restringe valores de columnas a propiedades reales deT.
¿Cuándo debes proporcionar el tipo genérico explícitamente vs confiar en la inferencia?
- Confía en la inferencia en la mayoría de casos -- reduce boilerplate.
- Proporciona explícitamente cuando la inferencia falla con combinaciones de props complejas:
<List<User> items={users} ...>. - También útil cuando el valor inicial no captura el tipo completo (ej., arrays vacíos).
Trampa: ¿Qué pasa si limitas excesivamente un genérico?
- El componente se vuelve menos reutilizable porque menos tipos satisfacen la limitación.
- Solo limita cuando realmente accedes a propiedades específicas dentro del componente.
- Un
<T>sin limitación acepta cualquier tipo, maximizando flexibilidad.
¿Puedes tener múltiples type parameters en un componente genérico o 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 };
}- Sí. Cada type parameter se infiere independientemente del argumento correspondiente.
¿Cómo ayuda as const con los tipos de retorno de hooks genéricos?
- Sin
as const, el tipo de retorno de[value, setValue]se infiere como(T | SetStateAction<T>)[]. - Con
as const, se convierte en una tuple readonly[T, Dispatch<SetStateAction<T>>]. - Esto preserva los tipos basados en posición para destructuring.