Menu

React con TypeScript: tipar props, estado y eventos

Cómo tipar una app de React con TypeScript: props y children, useState con uniones y null, useRef para elementos del DOM, manejadores de eventos, contexto, componentes genéricos y props de elementos nativos.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

React con TypeScript significa escribir componentes en archivos .tsx donde las props, el estado, las refs y los manejadores de eventos tienen tipos, para que el compilador detecte una prop faltante o un manejador de eventos equivocado antes de ejecutar el código. Los tipos desaparecen al hacer el build: lo que se ejecuta en el navegador es el mismo JavaScript que habrías escrito sin ellos. Los editores en vivo de esta página ejecutan ese JavaScript, y la versión tipada está junto a cada uno en un bloque estático.

Aquí está el mismo Badge con tipos. count es opcional (?), así que el segundo badge usa el valor por defecto 0, y children acepta tanto un elemento como una cadena simple.

type BadgeProps = {
    label: string;
    count?: number;
    children: React.ReactNode;
};

function Badge({ label, count = 0, children }: BadgeProps) {
    return (
        <div>
            <strong>{label}</strong> ({count})
            <div>{children}</div>
        </div>
    );
}

<Badge count={3}>Hi</Badge>;
// Error: Property 'label' is missing in type '{ children: string; count: number; }'

Crear un proyecto con TypeScript

Vite tiene una plantilla de React con TypeScript:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Obtienes archivos .tsx, un tsconfig.json con strict activado y las definiciones de tipos de React (@types/react, @types/react-dom) ya instaladas. Vite quita los tipos mientras sirve y hace el build, pero no los comprueba; el script build de la plantilla ejecuta tsc primero, y tu editor los comprueba mientras escribes. Frameworks como Next.js configuran TypeScript de la misma forma cuando creas un proyecto con su CLI.

Tipar las props

Describe las props como un tipo de objeto y anota el parámetro desestructurado. Unos pocos patrones cubren la mayoría de los componentes:

interface UserCardProps {
    name: string;                       // required
    age?: number;                       // optional, may be undefined
    role: 'admin' | 'member';           // a union of allowed strings
    tags: string[];
    onSelect: (id: string) => void;     // a callback prop
    children?: React.ReactNode;         // anything React can render
}

function UserCard({ name, age, role, tags, onSelect, children }: UserCardProps) {
    // ...
}

type o interface. Ambos funcionan para las props. interface se puede extender con extends y fusionar declarándola dos veces; type además puede expresar uniones, intersecciones y tipos mapeados. La mayoría de los equipos eligen uno y lo usan en todas partes.

children. Usa React.ReactNode para todo lo que va entre las etiquetas: elementos, cadenas, números, arrays, null. La página de props explica cómo llega children en primer lugar.

Por qué React.FC es opcional. const Badge: React.FC<BadgeProps> = (...) => ... tipa toda la función en lugar del parámetro. Desde los tipos de React 18 ya no agrega children por ti, no puede recibir un parámetro de tipo para un componente genérico, y no aporta nada que no aporte un parámetro tipado. El código antiguo lo usa mucho; las funciones normales con props tipadas son la opción habitual en el código nuevo.

Tipar useState

Para un valor inicial simple, la inferencia basta: useState(0) es un number, useState('') un string. Pasa un argumento de tipo cuando el estado puede contener más de lo que sugiere su valor inicial: un valor que empieza como null, o una de varias cadenas fijas.

Haz clic en "Load user" y el estado pasa de idle a loading y a done. El estado tipado:

type User = { id: number; name: string };
type Status = 'idle' | 'loading' | 'done';

const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<Status>('idle');

user.name;          // Error: 'user' is possibly 'null'
user?.name;         // OK
setStatus('ready'); // Error: Argument of type '"ready"' is not assignable to parameter of type 'SetStateAction<Status>'

Sin <User | null>, useState(null) infiere el tipo null y setUser(data) no compila. La unión te obliga a manejar el caso vacío, que es exactamente la comprobación user ? ... : ... del ejemplo en funcionamiento.

useRef para elementos del DOM

Una ref que apunta a un elemento del DOM recibe el tipo del elemento y empieza en null, porque el elemento no existe hasta que React lo conecta después del primer renderizado.

Escribe algo y luego haz clic en "Log the value": la consola imprime lo que escribiste, leído directamente del elemento del DOM. La versión tipada:

const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current is HTMLInputElement | null

inputRef.current?.focus();    // OK
inputRef.current.focus();     // Error: 'inputRef.current' is possibly 'null'

const timerId = useRef<number | null>(null);  // a mutable value, not a DOM node

Usa la interfaz de elemento que corresponda a la etiqueta: HTMLInputElement, HTMLDivElement, HTMLCanvasElement, HTMLButtonElement. En los tipos de React 19, useRef siempre recibe un argumento y devuelve un RefObject cuyo current puedes asignar, así que la misma forma sirve para refs del DOM y para valores como el id de un temporizador. Consulta useRef para el lado de ejecución.

Tipos de los manejadores de eventos

Los manejadores en línea no necesitan anotación: en onChange={(e) => setName(e.target.value)} TypeScript conoce e por la prop a la que se pasa. Anota cuando muevas un manejador a su propia función.

Envía y la consola registra la dirección; preventDefault evita que la página se recargue. Con tipos:

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setEmail(e.target.value); // e.target is HTMLInputElement, value is string
}

function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
}

function handleClick(e: React.MouseEvent<HTMLButtonElement>) {}
function handleKey(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === 'Enter') { /* ... */ }
}

Desde @types/react 19.2.10, onSubmit se tipa con React.SubmitEvent y el antiguo React.FormEvent está marcado como obsoleto; con una versión anterior de los tipos, escribe React.FormEvent<HTMLFormElement>. El parámetro de tipo es el elemento al que está conectado el manejador. Si no estás seguro del tipo, pasa el cursor sobre la prop onChange en tu editor, o tipa todo el manejador de una vez: const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { ... }.

Tipar el contexto

Dale a createContext el tipo del valor. Si existe un valor por defecto razonable, pásalo, y cada consumidor recibe un valor no nulo:

type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');

const theme = useContext(ThemeContext); // Theme

Cuando no hay un buen valor por defecto (un usuario con sesión iniciada, un store), empieza con null y envuelve useContext en un hook que lance un error si un componente se usa fuera del proveedor. Así cada llamador recibe el tipo no nulo y un mensaje de error claro en lugar de un fallo en lo más profundo.

type Auth = { user: string; logout: () => void };
const AuthContext = createContext<Auth | null>(null);

export function useAuth(): Auth {
    const value = useContext(AuthContext);
    if (!value) throw new Error('useAuth must be used inside <AuthProvider>');
    return value;
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
    const [user, setUser] = useState('Ada');
    return (
        <AuthContext value={{ user, logout: () => setUser('') }}>
            {children}
        </AuthContext>
    );
}

<AuthContext value={...}> es la sintaxis de proveedor de React 19; <AuthContext.Provider value={...}> se tipa de la misma forma.

Componentes genéricos

Una lista, tabla o select que funciona con cualquier tipo de elemento es un componente genérico: un parámetro de tipo conecta la prop items con el callback renderItem.

El List tipado:

type ListProps<T> = {
    items: T[];
    renderItem: (item: T) => React.ReactNode;
    getKey: (item: T) => string | number;
};

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

<List items={books} getKey={(b) => b.isbn} renderItem={(b) => b.title} />;
// b is inferred as the book type, so b.titel would be an error

TypeScript infiere T a partir de items, así que quienes lo usan nunca escriben el parámetro de tipo. En un archivo .tsx, una función flecha necesita <T,> en lugar de <T>, porque <T> solo se lee como una etiqueta JSX.

Extender las props de elementos nativos

Un envoltorio alrededor de un button o un input debería aceptar todos los atributos que acepta el elemento nativo. React.ComponentProps<'button'> es ese conjunto completo; agrega tus propias props encima y propaga el resto.

type ButtonProps = React.ComponentProps<'button'> & {
    variant?: 'primary' | 'ghost';
};

function Button({ variant = 'primary', style, ...rest }: ButtonProps) {
    return (
        <button
            {...rest}
            style={{ fontWeight: variant === 'primary' ? 600 : 400, ...style }}
        />
    );
}

<Button type="submit" disabled onClick={(e) => console.log(e.currentTarget)}>
    Save
</Button>;

onClick, disabled, type y aria-* quedan todos tipados sin enumerarlos. En React 19 ref es una prop normal, y ComponentProps<'button'> la incluye, así que <Button ref={buttonRef}> llega al nodo del DOM mediante la propagación sin forwardRef. Usa ComponentPropsWithoutRef<'button'> cuando quieras dejar fuera ref, y ComponentProps<typeof UserCard> para leer las props de uno de tus propios componentes.

Errores de tipos comunes

"'ref.current' is possibly 'null'". El elemento no existe durante el primer renderizado. Usa ref.current?.focus(), o comprueba if (ref.current) antes de usarlo.

"Type 'string' is not assignable" en un estado de unión. const [status, setStatus] = useState('idle') infiere string, que es más amplio de lo que quieres, y pasarlo a una prop tipada como Status falla. Escribe useState<Status>('idle').

Evento tipado con el elemento equivocado. React.ChangeEvent<HTMLInputElement> en un select hace que e.target tenga el tipo equivocado. El parámetro de tipo debe coincidir con el elemento al que está conectado el manejador.

Usar any para silenciar un error. Desactiva la comprobación para todo lo que toca ese valor. Prefiere unknown y acótalo, o corrige el tipo en su origen.

Preguntas frecuentes

¿Cómo creo una app de React con TypeScript?

Ejecuta npm create vite@latest my-app -- --template react-ts, luego npm install y npm run dev. Los componentes van en archivos .tsx y Vite quita los tipos al hacer el build; ejecuta tsc (el script build de la plantilla lo hace) para comprobarlos.

¿Cuál es el tipo de children en React?

React.ReactNode. Acepta todo lo que React puede renderizar: elementos, cadenas, números, arrays, null y undefined. Usa React.ReactElement solo cuando necesites exactamente un elemento.

¿Cómo tipo useState con null?

Pasa el tipo explícitamente como una unión: useState<User | null>(null). Sin el argumento de tipo, TypeScript infiere null como el único valor permitido.

¿Cuál es el tipo de un evento onChange en React?

React.ChangeEvent<HTMLInputElement> para un input (HTMLTextAreaElement o HTMLSelectElement para esos elementos). Así e.target.value queda tipado como string.

¿Debo usar React.FC?

Es opcional. Tipar directamente el parámetro de props (function Card({ title }: CardProps)) hace el mismo trabajo, se lee de forma más sencilla y funciona con genéricos. React.FC ya no agrega children de forma implícita, así que aporta poco.

¿Debo usar type o interface para las props?

Cualquiera funciona. interface se puede extender y fusionar; type puede expresar uniones y tipos mapeados. Elige una convención para el código y mantenla.

Ilustración de los lenguajes de programación de Coddy

Aprende a programar con Coddy

COMENZAR