Skip to content
This page has been auto-translated and may contain errors.View in English

TypeScript en React

Un padre renombra una prop de word a currentWord. Tres hijos siguen leyendo word, y nada lo señala: la app compila, la página carga, y el primer usuario que hace clic en el botón que renderiza uno de esos hijos ve una pantalla en blanco. TypeScript agrega una capa de tipos sobre JavaScript: cada valor tiene un tipo declarado o inferido, y el compilador señala el código que no coincide antes de que se ejecute.

En React eso paga exactamente donde se esconden errores como ese, en los puntos donde los datos se mueven entre componentes. Una prop tipada es un contrato: el padre debe enviar la forma correcta, el hijo puede confiar en lo que llega, y tu editor autocompletará ambos lados.

Este capítulo cubre las partes específicas de React: tipar state, props, children, function props y valores de retorno de componentes. Asume que ya conoces lo básico de TypeScript, unions, tipos personalizados y genéricos; la sección abre con lecciones de repaso sobre exactamente eso, y luego lo pone todo en práctica reconstruyendo el juego Assembly: Endgame en TypeScript.

Configuración inicial: plantilla react-ts de Vite

Vite incluye una versión TypeScript de su plantilla de React. Una bandera en el momento de crear el proyecto te da un proyecto completamente configurado:

bash
npm create vite@latest my-react-app -- --template react-ts

Los archivos de componente usan la extensión .tsx en lugar de .jsx (TypeScript más JSX), y módulos simples sin JSX usan .ts en lugar de .js. Todo lo demás sobre la configuración funciona como siempre; Vite compila los tipos y envía JavaScript simple al navegador.

Una trampa viene con eso: los errores de tipo aparecen en tu editor y fallan npm run build, que ejecuta tsc -b antes de agrupar, pero el servidor de desarrollo elimina las anotaciones sin verificarlas. npm run dev servirá un proyecto lleno de errores de tipo, así que una app que se ejecuta no es prueba de que los tipos pasen.

Tipar state

useState generalmente se tipa a sí mismo. El hook infiere su tipo del valor inicial que pasas, así que el state creado con un valor inicial real no necesita anotación en absoluto:

tsx
const [currentWord, setCurrentWord] = useState(getRandomWord())

Si getRandomWord retorna un string, currentWord es un string y setCurrentWord solo acepta strings. Llama a setCurrentWord(true) y TypeScript lo señala al instante: un boolean no es asignable donde se espera un string. Esa es la inferencia haciendo su trabajo, y para la mayoría del state puedes dejarla sola.

La inferencia falla cuando el valor inicial está vacío o es demasiado amplio para describir el futuro del state. Un array vacío no dice nada sobre qué contendrá, así que useState([]) infiere never[], un array cuyos elementos nunca pueden ser nada, lo que hace que cada push sea un error. Aquí es donde recurres a la forma explícita: useState es una función genérica, y pasas el argumento de tipo entre llaves angulares.

tsx
const [guessedLetters, setGuessedLetters] = useState<string[]>([])

Ahora el state es un array de strings aunque el valor inicial sea un array vacío, y tanto el valor como el setter lo refuerzan. El mismo movimiento maneja state nullable, donde el valor comienza como null y solo después se convierte en algo real:

tsx
type Word = { text: string; difficulty: number }

const [selectedWord, setSelectedWord] = useState<Word | null>(null)

La union Word | null le dice a TypeScript ambas formas que este state puede legalmente contener. Cada lectura de selectedWord ahora te obliga a manejar el caso null antes de tocar .text, lo que convierte un crash clásico en tiempo de ejecución en un empujón en tiempo de compilación. La mecánica del state en sí es inalterada; TypeScript solo fija qué está permitido que el state contenga.

Tipar props de componentes

Las props llegan como un objeto, así que tiparlas significa anotar ese objeto. Para un componente con una o dos props, una anotación inline funciona:

tsx
function ConfettiContainer({ isGameWon }: { isGameWon: boolean }) {
  // ...
}

Los tipos inline se vuelven desordenados a medida que las props se multiplican. El patrón estándar es un type alias nombrado, convencionalmente llamado ComponentNameProps, declarado encima del componente:

tsx
type GameStatusProps = {
  isGameWon: boolean
  wrongGuessCount: number
  message?: string
}

function GameStatus({ isGameWon, wrongGuessCount, message }: GameStatusProps) {
  // ...
}

El ? en message la marca como opcional: los padres pueden omitirla, y dentro del componente su tipo es string | undefined. Una declaración interface GameStatusProps { ... } funciona exactamente en la misma posición; para tipar props las dos son intercambiables, y el curso se mantiene con type aliases por consistencia con los tipos personalizados que construye en otros lugares.

Si un componente acepta children, tipalo como ReactNode, el tipo amplio que cubre todo lo que React puede renderizar: elementos, strings, números, fragments, arrays de todos estos.

tsx
import { type ReactNode } from 'react'

type CardProps = {
  title: string
  children: ReactNode
}

Cómo fluyen los children a través de un componente se cubre en Children y composition; ReactNode es el tipo que los describe. Todo sobre cómo se comportan las props en tiempo de ejecución permanece igual. La anotación de tipo agrega el contrato encima.

Tipar function props

Los componentes a menudo reciben funciones como props: un click handler, un callback que reporta un valor hacia arriba. El tipo para una function prop usa sintaxis de flecha: la lista de parámetros con sus tipos, una flecha, y el tipo de retorno.

tsx
type NewGameButtonProps = {
  startNewGame: () => void
}

type LetterButtonProps = {
  letter: string
  onGuess: (value: string) => void
}

() => void describe una función que no toma argumentos y no retorna nada, la forma de la mayoría de los handlers de estilo evento. (value: string) => void dice que el hijo llamará a la función con un string, así que la implementación del padre debe aceptar uno. Pasa un handler cuyos tipos de parámetro no coincidan y el error aparece en el call site JSX, en el padre, en tiempo de compilación.

Sin embargo, la verificación hace dos concesiones, y ambas parecen el verificador de tipos dejando algo pasar. Un handler puede declarar menos parámetros que los que el tipo lista, así que onGuess={() => setOpen(true)} satisface (value: string) => void. Y () => void acepta una función que retorna un valor, así que startNewGame={async () => { await saveScore() }} type-checkea aunque devuelva una promesa que nada espera. El compilador detecta tipos de parámetro incorrectos en lugar de cada diferencia en la forma.

Tipos de retorno y valores derivados

Pasa el mouse sobre un componente function y TypeScript ya sabe que retorna React.JSX.Element, inferido del JSX en la declaración de retorno. (El namespace JSX vive dentro del módulo react ahora en lugar de ser global, así que escribir la anotación a mano significa importarla.)

Puedes dejar esa inferencia sola, y muchos codebases lo hacen. Anotar el tipo de retorno es una decisión sobre strictness: garantiza que el componente siempre retorna un elemento único, lo que algunos equipos valoran en codebases más grandes. Eso es una promesa más estricta que la que React hace, ya que un componente legalmente puede retornar un string, un número, un array, o null, y una anotación JSX.Element simple rechaza todos esos hasta que los amplíes.

tsx
import { type JSX } from 'react'

function Header(): JSX.Element {
  return <h1>Assembly: Endgame</h1>
}

function ConfettiContainer({ isGameWon }: { isGameWon: boolean }): JSX.Element | null {
  if (!isGameWon) return null
  return <Confetti />
}

ConfettiContainer a veces no renderiza nada retornando null, el mismo movimiento usado en renderizado condicional, así que su tipo de retorno anotado es la union JSX.Element | null. El mismo hábito se extiende a los valores derivados dentro del componente: el tipo de retorno de una arrow function se añade después de sus paréntesis de parámetros, y un map sobre datos que produce JSX produce un JSX.Element[]. La mayoría de estas anotaciones reafirman lo que la inferencia ya concluyó, así que trátalas como documentación opcional. Los que consistentemente ganan su espacio se sientan en los límites: props, state vacío o nullable, y funciones cuyas firmas otros componentes dependen.

Importar tipos compartidos

Los tipos se exportan e importan como cualquier otro binding, lo que mantiene una definición sirviendo a toda la app. Los módulos de datos comúnmente exportan su forma junto con los datos:

tsx
// languages.ts
export type Language = {
  name: string
  backgroundColor: string
  color: string
}

// LanguageChips.tsx
import { type Language } from './languages'

type LanguageChipsProps = {
  languages: Language[]
}

La palabra clave type en la importación la marca como type-only, así que desaparece completamente del JavaScript compilado. Una vez que un tipo como Language vive en un lugar, cada componente que toca esos datos importa el mismo contrato, y cambiar la forma en un archivo superficie cada call site que necesita actualización.

Los objetos de evento son el lugar donde tipar muerde primero. Un handler inline obtiene su tipo de evento gratis, porque React sabe qué <input> pasa a onChange, así que event en onChange={event => setGuess(event.target.value)} ya está tipado. Extrae ese handler en una función nombrada y el contexto desaparece: el parámetro se convierte en un any implícito, y el type checking estricto de la plantilla convierte eso en un error. Anotatlo con el tipo de evento React que coincida con el elemento.

tsx
function GuessInput() {
  const [guess, setGuess] = useState('')

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setGuess(event.target.value)
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setGuess('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={guess} onChange={handleChange} />
    </form>
  )
}

El tipo de elemento en las llaves angulares es lo que hace event.target.value un string; pon HTMLElement allí en su lugar y target no lleva value en absoluto. La misma forma cubre el resto: React.MouseEvent<HTMLButtonElement> para clics, React.KeyboardEvent<HTMLInputElement> para pulsaciones de tecla. Cada uno también es importable por nombre, como en import { type ChangeEvent } from 'react'. Lo que los handlers hacen en tiempo de ejecución es territorio de events y forms; la anotación solo nombra lo que React les entrega.

El código React + TypeScript más antiguo tipa componentes como const Header: React.FC = () => .... React.FC anota toda la función en lugar de su valor de retorno, y cayó en desgracia por razones concretas: históricamente añadía silenciosamente children a las props de cada componente sin importar si el componente aceptaba alguno, y complica componentes genéricos. La convención actual es la que este capítulo usa, una función simple con props tipadas y un retorno JSX.Element inferido o anotado.

Prefiere derivar tipos sobre re-declararlos. Cuando un componente necesita parte de una forma existente, utility types mantienen la única fuente de verdad intacta: un inline style object de un chip que lleva los campos de color es Pick<Language, 'backgroundColor' | 'color'>. Pick es una whitelist, así que un campo agregado a Language más tarde no puede terminar en el objeto que pasas a style. Recurre a Omit en el trabajo opuesto, eliminando una clave conocida de un tipo props, donde heredar lo que crece el tipo fuente es el comportamiento que quieres.

El mismo instinto aplica a los wrappers heavy en DOM: ComponentProps<'button'> de React trae cada prop que un button nativo acepta, así que un design-system button puede extenderlo en lugar de hand-listing onClick, disabled, y amigos.

Cada política de anotación falla a su propia manera, que es la parte que vale la pena planificar. Anota todo y cada refactor arrastra un diff a través de archivos que solo reafirman lo que la inferencia ya conocía, así que esas anotaciones se quedan atrás del código y comienzan a describir una forma que ya no tiene. Anota solo los límites y una mala inferencia, un any filtrándose de una dependencia sin tipo, se propaga silenciosamente a través de valores locales hasta que se encuentra con un límite que la rechaza.

La segunda falla es más barata de detectar, porque el límite es donde la anotación ya se sienta, así que haz annotate-the-seams el default y agrega una anotación local el momento en que el tipo inferido de un valor derivado te sorprende.

JunoTipa los límites, infiere el resto TypeScript es como etiquetar las cajas que pasas entre componentes.

El state creado con un valor inicial real se etiqueta a sí mismo, y cuando el valor inicial está vacío o null escribes la etiqueta tú mismo, como useState<string[]>([]). Las props obtienen un pequeño tipo nombrado como GameStatusProps listando cada prop y su tipo.

Una vez que las etiquetas están puestas, tu editor te advierte el momento en que algo de la forma equivocada llega, antes de que incluso ejecutes la app.

JunoTipa los límites, infiere el resto Deja que useState infiera de valores iniciales reales y pasa un genérico para los casos vacío y nullable, como useState<Word | null>(null).

Dale a cada componente un alias ComponentNameProps, marca opcionales con ?, tipa children como ReactNode, y escribe function props como firmas como (value: string) => void. Un handler de evento extraído necesita su parámetro anotado, como en React.ChangeEvent<HTMLInputElement>, porque solo los handlers inline obtienen ese tipo gratis.

Comparte tipos exportándolos del módulo que posee los datos e importándolos con import { type Language }.

JunoTipa los límites, infiere el resto Anota la superficie pública, props y firmas exportadas, y deja que la inferencia maneje valores derivados privados.

Sáltate React.FC a favor de funciones simples con props tipadas y un retorno JSX.Element o JSX.Element | null donde quieras la garantía. Deriva en lugar de duplicar: Pick y ComponentProps<'button'> mantienen una única fuente de verdad, así que los cambios de forma aparecen como errores de compilación en cada call site afectado.

Ese es el manual. Lo que comenzó como una función que retorna un poco de markup es ahora un toolkit completo: componentes y state, effects y datos, patrones de componentes reutilizables, routing, un modelo de renderizado para razonar sobre performance, y una capa de tipos que detecta errores en los límites antes de que alguien más los encuentre. Elige un proyecto que realmente quieras que exista, créalo con npm create vite@latest, y abre estos capítulos de nuevo cuando el proyecto comience a pedirlos.