Fundamentos de React: estado, efectos y render

Por 2026-09-20
ReactTypeScriptFrontendHooksJavaScript
Pantalla con código JavaScript sobre fondo oscuro — fundamentos de React: estado, efectos y render
Foto de Antonio Batinić en Pexels

Casi nadie se atasca en React por la sintaxis. Se atasca porque cree que React reacciona a los cambios, y React hace otra cosa: vuelve a ejecutar tu función entera y compara el resultado. Este es el modelo mental que hace que useState, useEffect y los re-renders dejen de darte sustos.

Escribí un libro de React y la parte que más me costó ordenar no fue la sintaxis. JSX se aprende en una tarde. Lo que cuesta explicar —y lo que veo fallar una y otra vez en código que llega a producción— es el modelo: qué hace React por debajo cuando tocas el estado, y por qué eso convierte en trampas cosas que en JavaScript normal son obvias.

Lo que sigue son las seis o siete ideas que, cuando encajan, hacen que el resto del framework se vuelva predecible.

React vuelve a ejecutar; no reacciona

La palabra «reactivo» engaña. Sugiere que React observa tus variables y actualiza la pantalla cuando cambian. No es así como funciona.

Un componente es una función. Renderizar significa ejecutar esa función otra vez, de arriba abajo. El JSX que devuelve es una descripción de cómo debería verse la interfaz. React compara esa descripción con la anterior y toca el DOM solo donde hay diferencias.

De ahí salen dos consecuencias que explican la mitad de las dudas de los primeros días.

La primera: una variable normal se reinicia en cada ejecución. Este código parece razonable y no hace nada visible.

function Buscador() {
  let filtro = ''   // vuelve a valer '' en cada render

  return (
    <input onChange={e => { filtro = e.target.value }} />
  )
}

La segunda: React solo vuelve a ejecutar la función cuando cambia algo que él controla. filtro es tuyo, no suyo. Nadie le avisa.

useState resuelve las dos cosas a la vez: guarda el valor fuera de la función para que sobreviva, y avisa a React de que toca volver a ejecutar. Esa es toda su magia.

El estado vive fuera de tu componente

Aquí está la idea que más rendimiento da por línea explicada, y que casi ningún tutorial cuenta.

React guarda el estado en una lista asociada a la posición del componente en el árbol, y lo indexa por el orden en que llamas a los hooks. Primera llamada a useState, primera casilla. Segunda llamada, segunda casilla. Cada render recorre esa lista en el mismo orden.

Eso explica tres cosas aparentemente inconexas de un solo golpe.

Por qué los hooks van siempre arriba y nunca dentro de un if. Si el orden de las llamadas cambia entre renders, React entrega la casilla equivocada a la variable equivocada.

Por qué useState(props.valor) ignora los cambios de esa prop. El argumento que le pasas es el valor con el que se inicializa la casilla. En cuanto la casilla existe, React devuelve lo que hay guardado y descarta el argumento. Funciona como valor de partida y comunica mal si lo llamas valor; llámalo valorInicial y el siguiente que lea el código lo entenderá.

Por qué la prop key reinicia un componente. Si cambias la key, para React ya no es el mismo componente en la misma posición: es otro. Le asigna casillas nuevas y el estado anterior se descarta. Es la forma más limpia de resetear un formulario cuando cambia el registro que estás editando, y ahorra sincronizaciones manuales que siempre acaban mal.

<FormularioCliente key={clienteId} cliente={cliente} />

Cambia clienteId y el formulario nace de cero. Sin efectos, sin banderas, sin useEffect que vacíe campos.

Cada render es una foto, no una ventana

Dentro de una ejecución del componente, tus valores no cambian nunca. Son constantes capturadas en ese momento. Esta frase sola resuelve la mayoría de los «pero si acabo de actualizarlo».

Actualizar el estado encola un render; no modifica una variable. Por eso esto imprime el valor viejo:

function guardar() {
  setPendientes(pendientes + 1)
  console.log(pendientes)   // el valor de ESTE render, todavía el anterior
}

Y por eso esto suma uno, no tres:

setPendientes(pendientes + 1)
setPendientes(pendientes + 1)
setPendientes(pendientes + 1)

Las tres llamadas leen el mismo pendientes congelado. Las tres dicen lo mismo: «pon el valor actual más uno». La forma funcional arregla justo eso, porque React le pasa a cada una el resultado de la anterior:

setPendientes(n => n + 1)

La misma foto explica el bug clásico del temporizador: un setInterval creado en el primer render ve para siempre los valores de ese primer render. Con la forma funcional deja de importar, porque el valor lo pone React en el momento de aplicarlo.

Regla práctica: si el nuevo valor depende del anterior, usa la forma funcional. Si depende de un evento —lo que el usuario acaba de escribir, lo que ha llegado del servidor— la forma directa está bien.

React compara referencias

Cuando decides si el estado cambió, tú miras el contenido. React mira la referencia.

lineas.push(nuevaLinea)   // el array sigue siendo el mismo objeto
setLineas(lineas)         // React: "es idéntico, aquí no ha pasado nada"

La pantalla se queda igual y el bug es invisible, porque los datos sí han cambiado. La regla es crear siempre un valor nuevo: setLineas([...lineas, nuevaLinea]), setFicha({ ...ficha, estado: 'enviada' }).

Lo interesante es que la misma comparación por referencia muerde por el otro lado. Un objeto creado durante el render es nuevo en cada render, aunque su contenido sea idéntico:

const opciones = { orden: 'fecha', pagina }

useEffect(() => {
  cargarReservas(opciones)
}, [opciones])   // opciones es distinto cada vez: el efecto se dispara siempre

Ese fragmento es un bucle disfrazado. La solución casi nunca es envolver opciones en useMemo: es declarar en las dependencias los valores primitivos que de verdad importan, [pagina], y construir el objeto dentro del efecto.

La pregunta que ahorra la mitad de los bugs

Antes de escribir useState, contesta esto: ¿esto tiene que ser estado?

Tres filtros, en este orden.

¿Se puede calcular a partir de algo que ya tengo? Entonces es una variable derivada. Calcúlala durante el render y olvídate.

const total = lineas.reduce((s, l) => s + l.importe, 0)
const hayErrores = lineas.some(l => l.error)

Un segundo useState para el total obliga a mantener dos verdades sincronizadas a mano, y la sincronización se rompe siempre en el sitio donde no miraste: el borrado múltiple, el deshacer, la carga inicial.

¿Viene del servidor? Entonces es caché, no estado. Guardar la respuesta en useState te obliga a inventar de nuevo el estado de carga, el de error, la invalidación y la recarga, en cada componente y ligeramente distinto cada vez. Para eso hay herramientas hechas —TanStack Query es la estándar— y la diferencia en un proyecto real es de cientos de líneas.

¿Lo necesita alguien más? Entonces sube al ancestro común más cercano. Al más cercano, no al más alto: subirlo de más hace que un cambio pequeño vuelva a renderizar medio árbol.

Lo que sobrevive a los tres filtros es estado de verdad, y suele ser menos de lo que parecía: lo que el usuario está escribiendo, lo que ha abierto, lo que ha seleccionado.

useEffect no significa «cuando pase X»

El nombre y los ejemplos de internet empujan a leerlo como un disparador: «ejecuta esto cuando cambie aquello». Con esa lectura, el array de dependencias parece un panel de configuración y da la sensación de que quitando entradas se optimiza.

La lectura correcta es otra. Un efecto sincroniza tu componente con algo que vive fuera de React: una petición, un temporizador, una suscripción, el título de la pestaña, una librería que toca el DOM. Y el array de dependencias no configura nada: declara qué valores lee el efecto. Si mientes ahí, el efecto se queda congelado con los valores del primer render y deja de sincronizar.

Quitar una dependencia «para que se ejecute menos» no es una optimización. Es un bug con buena intención. Si lo que quieres es lanzar menos peticiones, lo que necesitas es un debounce: esperar a que el usuario pare de escribir. Eso se hace con un temporizador dentro del efecto y su limpieza, manteniendo las dependencias honestas.

Antes de escribir un efecto, tres preguntas:

PreguntaSi la respuesta es sí
¿Esto pasa porque el usuario ha hecho algo?Va en el manejador del evento, no en un efecto
¿Esto se puede calcular durante el render?Es una variable derivada
¿Esto conecta con algo de fuera de React?Ahora sí: es un efecto

El antipatrón más común es el primero: un useState llamado enviado que se pone a true en el clic, y un efecto que vigila enviado para lanzar la petición. El manejador ya tenía toda la información en el momento del clic. Ese estado extra solo añade un camino más por el que perderse.

Se ejecuta dos veces, y eso es la prueba

En desarrollo verás efectos que se disparan dos veces al montar. Es StrictMode, es deliberado y solo pasa en desarrollo.

Lo que hace es montar el componente, desmontarlo y volverlo a montar, para comprobar una cosa concreta: si tu efecto sabe deshacerse a sí mismo. Un efecto bien escrito devuelve una función de limpieza, esa limpieza cancela lo que arrancó y el doble montaje pasa sin dejar rastro.

Y aquí está el motivo por el que merece la pena escucharle. El mismo fallo que provoca dos peticiones en desarrollo provoca un bug real en producción: respuestas que llegan desordenadas. El usuario pide la página 1, la petición va lenta; pide la página 2, esa vuelve antes y se pinta; llega por fin la de la página 1 y sobrescribe la pantalla con datos viejos. Nadie reproduce ese bug en local con una API rápida, y en producción aparece cada día.

La cura es la misma en los dos casos:

useEffect(() => {
  const ctrl = new AbortController()

  cargarReservas(hotelId, ctrl.signal)
    .then(setReservas)
    .catch(descartarSiEsAbort)

  return () => ctrl.abort()
}, [hotelId])

La clave está en cuándo se llama esa función de limpieza. React la ejecuta antes de cada nueva ejecución del efecto, además de al desmontar. Así que cuando hotelId cambia, lo primero que ocurre es que la petición anterior se cancela. El orden de llegada deja de importar.

Por eso desactivar StrictMode para «arreglar» el doble render sale caro: apagas el detector y te quedas con el incendio.

Cuándo useState se queda corto

useState cubre muchísimo. Tiene dos límites y conviene reconocerlos por síntomas, no por tamaño de la aplicación.

Síntoma uno: un manejador que llama a tres setters seguidos. Si al pulsar «enviar» pones cargando a true, error a null y intentos a +1, esos tres valores son en realidad uno solo con varias caras. El estado permite además combinaciones imposibles: cargando en true y error con mensaje a la vez. Ahí entra useReducer: las transiciones se centralizan en una función pura, las acciones describen lo que ha pasado (ENVIO_INICIADO, ENVIO_FALLIDO) y esa función se prueba sin montar nada. Con TypeScript la ganancia es doble, porque una unión discriminada de acciones deja que el compilador te exija tratar todos los casos.

Síntoma dos: props que atraviesan componentes que no las usan. Ese es el prop drilling y Context lo resuelve. Con una advertencia que conviene tener clara desde el principio: Context es un transporte, no un gestor de estado. Cuando su valor cambia, todos sus consumidores se vuelven a renderizar, usen o no la parte que ha cambiado. Va perfecto para lo que cambia poco —tema, idioma, usuario autenticado— y va mal para lo que cambia muchas veces por segundo.

Si necesitas Context sobre estado que cambia a menudo, hay dos salidas. La barata: partirlo en dos contextos, uno con el estado y otro con la función de despacho, porque la segunda nunca cambia y los componentes que solo despachan dejan de re-renderizar. La definitiva: un almacén externo como Zustand, donde cada componente se suscribe exactamente a la parte que lee.

El orden sano es local, subir al padre, Context y almacén. Saltar directamente al último escalón «por si acaso» es la forma habitual de acabar con un estado global que nadie sabe quién toca.

Optimizar con evidencia

useMemo, useCallback y React.memo tienen coste propio: React guarda valores y compara dependencias en cada render. En cálculos baratos, el remedio cuesta más que la enfermedad.

Y hay una trampa muy extendida: useCallback sin un React.memo abajo no sirve de nada. Estabilizas una función para que el hijo no re-renderice, pero si el hijo no está memoizado re-renderiza igual. Las dos piezas funcionan juntas o no funcionan.

La regla es medir antes con el Profiler de React DevTools. Si nadie ha medido, lo más probable es que el useMemo que estás a punto de escribir solo añada ruido.

Hay un caso donde sí hay una herramienta específica y buena: el input que se congela al escribir porque cada tecla filtra una lista enorme. Eso se arregla con useTransition, marcando el filtrado como actualización no urgente. React atiende primero la tecla y procesa la lista cuando puede. El campo deja de trabarse sin tocar el algoritmo de filtrado.

Si vienes de Angular, o vas hacia él

Escribí también el libro de Angular, así que la comparación me sale de haber tenido que explicar los dos modelos seguidos. Son distintos de verdad, y saber en qué lo son ahorra mucho tiempo.

React vuelve a ejecutar la función entera y tú le dices qué recordar. De ahí salen los hooks, los arrays de dependencias y la memoización: son el mecanismo con el que marcas qué sobrevive a la re-ejecución y qué se recalcula.

Angular, desde la versión 17, rastrea quién lee qué. Un signal sabe qué nodos de la plantilla dependen de él y actualiza solo esos. La función del componente no se vuelve a ejecutar entera.

La consecuencia práctica es que la pregunta difícil cambia de sitio. En React es «¿qué se recalcula y cuándo?». En Angular es «¿de quién es este signal y en qué servicio vive?», que se contesta con inyección de dependencias.

Ninguno de los dos gana. React pide menos conceptos para empezar y te cobra las trampas de la re-ejecución. Angular pide más de entrada —CLI, inyección, decoradores— y a cambio la reactividad sorprende bastante menos. Si estás eligiendo el primero, mira las ofertas de trabajo de tu zona y aprende el que aparezca más; el segundo se aprende luego en semanas, precisamente porque el modelo mental ya lo tienes. Lo cuento con más detalle en los fundamentos de Angular.

Cómo saber que ya lo tienes

Si puedes contestar estas seis en voz alta, sin dudar, el modelo está dentro:

  1. ¿Qué significa exactamente que un componente «se renderiza»?
  2. ¿Por qué el console.log justo después de actualizar el estado imprime el valor viejo?
  3. ¿Por qué array.push seguido del setter deja la pantalla igual?
  4. ¿Qué declara realmente el array de dependencias de un efecto?
  5. ¿Cuándo llama React a la función de limpieza?
  6. ¿Qué tres preguntas te haces antes de crear un useState?

El día que estas seis se contestan solas, los errores dejan de ser misteriosos y pasan a ser trabajo normal de depuración.

Y si quieres el camino entero

Este artículo cubre el modelo mental, que es la parte que más atasca. El resto del oficio —TypeScript, Vite, enrutado, formularios, peticiones, y sobre todo cómo se ordena todo eso en una aplicación que mantienes durante años— pide bastante más espacio.

Lo escribí en Introducción a React: TypeScript desde cero, el ecosistema actual y un proyecto final completo, en el orden en el que las piezas se apoyan unas en otras. Te aviso de lo evidente: vendo el libro, y vendo consultoría, no cursos. El libro es lo que me habría gustado tener cuando me tocó ordenar esto por primera vez.

Y si lo que tienes delante no es aprender React sino un frontend de empresa que ya duele —re-renders que nadie sabe de dónde salen, estado global que toca todo el mundo, una migración parada— eso es otra conversación: cuéntame el caso.

¿Buscas un consultor IT para tu empresa? Conoce mis servicios de consultoría IT: arquitectura cloud, desarrollo fullstack y liderazgo técnico. ¿Empresa en Mallorca o Baleares? Consultor IT en Mallorca.

¿Listo para transformar tu stack tecnológico?

Hablemos sobre cómo llevar tus sistemas al siguiente nivel, optimizar el rendimiento y potenciar el talento de tu equipo.