TypeScript y React se llevan bien desde hace años, pero la integración tiene matices que no siempre son obvios: cuándo usar FC y cuándo no, cómo tipar hooks con genéricos, cómo distinguir los tipos de evento en los distintos elementos del DOM y cómo usar forwardRef correctamente. Esta guía cubre los patrones fundamentales con ejemplos listos para usar en proyectos reales.
FC vs función directa
La forma preferida de definir componentes en TypeScript no es usando React.FC sino una función normal con las props tipadas directamente. React.FC añade children implícito en versiones antiguas de React y oculta el tipo de retorno, lo que puede enmascarar errores:
// NO recomendado (React.FC):
const Saludo: React.FC<{ nombre: string }> = ({ nombre }) => (
<h1>Hola, {nombre}</h1>
);
// Recomendado (función directa con tipo de props explícito):
interface SaludoProps {
nombre: string;
clase?: string;
}
function Saludo({ nombre, clase }: SaludoProps): React.ReactElement {
return <h1 className={clase}>Hola, {nombre}</h1>;
}
// Con PropsWithChildren si el componente acepta hijos:
interface ContenedorProps extends React.PropsWithChildren {
titulo: string;
}
function Contenedor({ titulo, children }: ContenedorProps) {
return (
<div>
<h2>{titulo}</h2>
{children}
</div>
);
}
useState y useReducer tipados
// useState infiere el tipo del valor inicial:
const [contador, setContador] = React.useState(0); // number
const [nombre, setNombre] = React.useState(""); // string
// Cuando el valor inicial es null o undefined, hay que indicar el tipo:
const [usuario, setUsuario] = React.useState<Usuario | null>(null);
// useReducer con tipos explícitos:
type Accion =
| { tipo: "incrementar"; cantidad: number }
| { tipo: "reiniciar" }
| { tipo: "setNombre"; nombre: string };
interface Estado {
contador: number;
nombre: string;
}
function reducer(estado: Estado, accion: Accion): Estado {
switch (accion.tipo) {
case "incrementar": return { ...estado, contador: estado.contador + accion.cantidad };
case "reiniciar": return { ...estado, contador: 0 };
case "setNombre": return { ...estado, nombre: accion.nombre };
}
}
const [estado, dispatch] = React.useReducer(reducer, { contador: 0, nombre: "" });
Hooks personalizados tipados
// Hook genérico para fetch:
interface EstadoFetch<T> {
datos: T | null;
cargando: boolean;
error: string | null;
}
function useFetch<T>(url: string): EstadoFetch<T> {
const [estado, setEstado] = React.useState<EstadoFetch<T>>({
datos: null,
cargando: true,
error: null,
});
React.useEffect(() => {
fetch(url)
.then(r => r.json())
.then((datos: T) => setEstado({ datos, cargando: false, error: null }))
.catch((e: Error) => setEstado({ datos: null, cargando: false, error: e.message }));
}, [url]);
return estado;
}
// Uso:
const { datos, cargando } = useFetch<{ items: Producto[] }>("/api/productos");
Tipos de eventos del DOM
function Formulario() {
// ChangeEvent para inputs, selects y textareas:
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
const handleSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
console.log(e.target.value);
};
// FormEvent para el submit del formulario:
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const datos = new FormData(e.currentTarget);
};
// MouseEvent para clicks:
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.clientX, e.clientY);
};
// KeyboardEvent:
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") console.log("Enter presionado");
};
return (
<form onSubmit={handleSubmit}>
<input onChange={handleInput} onKeyDown={handleKeyDown} />
<button onClick={handleClick}>Enviar</button>
</form>
);
}
forwardRef con useImperativeHandle
// Definir el tipo del ref que expone el componente:
interface InputRef {
enfocar(): void;
limpiar(): void;
}
interface InputProps {
placeholder?: string;
etiqueta: string;
}
const InputConRef = React.forwardRef<InputRef, InputProps>(
({ placeholder, etiqueta }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
React.useImperativeHandle(ref, () => ({
enfocar() { inputRef.current?.focus(); },
limpiar() {
if (inputRef.current) inputRef.current.value = "";
},
}));
return (
<label>
{etiqueta}
<input ref={inputRef} placeholder={placeholder} />
</label>
);
}
);
// Uso en el componente padre:
function Padre() {
const inputRef = React.useRef<InputRef>(null);
return (
<>
<InputConRef ref={inputRef} etiqueta="Nombre" />
<button onClick={() => inputRef.current?.enfocar()}>Enfocar</button>
</>
);
}
Tipar correctamente los componentes React con TypeScript no es añadir tipos en todas partes: es conocer qué tipos infiere React automáticamente, qué hay que anotar explícitamente y qué patterns evitan tener que repetir tipos en cada uso. Un sistema de tipos bien diseñado en el frontend hace que los errores de props o de eventos aparezcan en el IDE, no en producción.
Imagen: Pexels / Myburgh Roux
