Si trabajas con React a diario, tarde o temprano descubres que dominar useState y useEffect marca la diferencia entre un componente que “funciona a ratos” y una interfaz sólida, mantenible y fácil de depurar. Ambos hooks parecen sencillos al principio, pero en cuanto empiezas a hacer peticiones HTTP, suscripciones o temporizadores, aparecen dudas, efectos que se ejecutan en bucle y estados que se quedan “viejos”.
En las siguientes líneas vamos a ver cómo usar correctamente useState y useEffect en React, desde lo básico hasta patrones avanzados, integrando buenas prácticas de rendimiento, gestión de dependencias, limpieza de efectos, uso con APIs externas y algunos trucos para evitar errores típicos. La idea es que, al terminar, puedas leer cualquier componente con hooks y entender qué está pasando, y sobre todo, que sepas diseñar los tuyos sin miedo a romper nada.
Qué son exactamente useState y useEffect y cuándo deberías usarlos
React introdujo los Hooks a partir de la versión 16.8 para poder gestionar estado y lógica de ciclo de vida dentro de componentes funcionales, sin necesidad de usar clases. El objetivo es que puedas componer lógica reutilizable de forma declarativa, aprovechando el propio lenguaje JavaScript (closures, funciones puras, etc.).
El hook useState sirve para añadir estado local a un componente funcional. En lugar de tener this.state y this.setState, declaras una pareja [valor, setValor] que React asociará al orden de tus llamadas a hooks en ese componente. Cada render tendrá su propia “versión” de ese estado.
Por su parte, useEffect es el hook pensado para efectos secundarios: código que se ejecuta después de que React haya pintado en el DOM y que interactúa con sistemas externos: peticiones de datos, suscripciones a websockets, acceso a localStorage, manipulación de la ventana, temporizadores con setInterval o setTimeout, etc.
Aunque se suele decir que useEffect equivale a componentDidMount, componentDidUpdate y componentWillUnmount combinados, es más preciso pensar que describe “qué debe sincronizarse con el exterior después de cada render”. Luego, el array de dependencias define cuándo se hace esa sincronización.

useState: gestionar estado local de forma declarativa
El hook useState se declara en la parte superior del componente y recibe un valor inicial. Devuelve un array con dos posiciones: el valor actual y una función para actualizarlo. Algo tan simple como un contador se define así:
Ejemplo básico de useState:
import { useState } from "react";
function ContadorBasico() {
const [count, setCount] = useState(0);
const incrementar = () => {
setCount(count + 1);
};
return (
<div>
<h1>Has pulsado el botón {count} veces</h1>
<button onClick={incrementar}>Púlsame</button>
</div>
);
}
En cada render, count representa el estado correspondiente a esa renderización concreta. Al llamar a setCount, React programa un nuevo render con el valor actualizado. Si la actualización depende del valor previo, es preferible usar la forma con función:
setCount(prev => prev + 1);
Esta sintaxis evita problemas cuando se encadenan varias actualizaciones seguidas, ya que React garantiza que prev es el último valor estable, incluso en modo concurrente.
useEffect: entender su ciclo de vida y el array de dependencias
La firma del hook de efecto es muy simple: useEffect(efecto, dependencias?). El primer argumento es una función de configuración (setup), que se ejecuta después de pintar el DOM. Opcionalmente, esa función puede devolver otra función de limpieza (cleanup) que React llamará antes de volver a ejecutar el efecto o al desmontar el componente.
La clave para usar bien useEffect es entender el array de dependencias. Ese array indica de qué valores “reactivos” depende la lógica del efecto: props, estados y variables o funciones definidas dentro del componente. React compara las dependencias actuales con las del render anterior usando Object.is. Si alguna cambia, primero ejecuta la limpieza anterior y luego configura el nuevo efecto.
Según cómo escribas ese array, obtienes distintos comportamientos:
- Sin segundo argumento: el efecto se ejecuta después de cada render.
- Array vacío []: el efecto se ejecuta solo una vez tras el primer render (modo “componentDidMount” y “componentWillUnmount”).
- Array con dependencias [a, b]: el efecto se ejecuta tras el primer render y cada vez que cambie alguna de esas dependencias.
Un ejemplo muy típico: actualizar el título del documento con un contador. Es el equivalente funcional moderno al patrón clásico de clases con componentDidMount + componentDidUpdate.
Ejemplo de useEffect sincronizando el título del documento:
import { useState, useEffect } from "react";
function ContadorConTitulo() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Pulsaste el botón ${count} veces`;
}, [count]);
return (
<div>
<h1>El botón ha sido pulsado {count} veces</h1>
<button onClick={() => setCount(count + 1)}>Púlsame</button>
</div>
);
}
En este caso, la dependencia es count, y debe estar en el array sí o sí, porque se utiliza dentro del efecto. Si intentases omitirla, eslint-plugin-react-hooks te avisaría; y si forzases el linter a callarse, acabarías con bugs difíciles de cazar donde el título no corresponde al valor real del contador.

Efectos sin saneamiento: lógica que solo necesita ejecutarse después de renderizar
Hay una categoría de efectos que no requieren limpieza explícita. Son aquellos en los que “haces algo y te olvidas”: registrar por consola, modificar el título del documento, hacer una petición HTTP puntual, medir algo solo una vez, etc.
En el mundo de las clases, esta lógica se repartía entre componentDidMount y componentDidUpdate, a menudo con código duplicado. Con hooks, esa duplicación desaparece porque un único useEffect puede manejar tanto el primer render como las actualizaciones.
Ejemplo sencillo de efecto sin saneamiento: obtener la ubicación del usuario con la API de geolocalización del navegador y guardarla en el estado:
import { useState, useEffect } from "react";
function UbicacionUsuario() {
const [latitud, setLatitud] = useState(0);
const [longitud, setLongitud] = useState(0);
useEffect(() => {
if (!navigator.geolocation) {
console.log(«El navegador no soporta la geolocalización»);
return;
}
navigator.geolocation.getCurrentPosition((pos) => {
setLatitud(pos.coords.latitude.toFixed(0));
setLongitud(pos.coords.longitude.toFixed(0));
});
}, []);
En este caso, solo queremos pedir la ubicación una vez, cuando el componente se monta. Por eso, el array de dependencias está vacío: no hay nada que deba volver a disparar el efecto. No hace falta limpieza porque no dejamos ningún intervalo, suscripción o listener activo.
Otro caso frecuente de efecto sin saneamiento es un log o un seguimiento de analítica cuando cambia cierta info, por ejemplo, registrar en qué sala de chat está el usuario o cuántos artículos tiene en el carrito, siempre que no se mantenga una conexión viva asociada a ese evento.
Efectos con saneamiento: suscripciones, intervalos y fugas de memoria
La otra gran categoría son los efectos que sí necesitan limpieza. Suele ocurrir cuando creas algo que debe desconectarse o cancelarse al cambiar las dependencias o desmontarse el componente: suscripciones a una API, listeners globales de eventos, temporizadores, websockets, etc.
El patrón es siempre el mismo: dentro del efecto inicializas el recurso y devuelves una función que lo deshace. React llamará a esa función de limpieza antes de ejecutar el efecto de nuevo y cuando el componente desaparezca del DOM.
Un ejemplo clásico: un contador que se incrementa automáticamente con un intervalo.
import { useState, useEffect } from "react";
const ContadorAutomatico = () => {
const [contador, setContador] = useState(0);
useEffect(() => {
const intervalo = setInterval(() => {
console.log(«Intervalo…»);
setContador((c) => c + 1);
}, 2000);
return () => {
console.log(«Limpiando intervalo»);
clearInterval(intervalo);
};
}, []);
return <div>{contador}</div>;
};
Aquí, el intervalo se crea al montar el componente y se borra al desmontarlo. Si olvidases llamar a clearInterval, el intervalo seguiría ejecutándose aunque el componente ya no se mostrase, provocando fugas de memoria y actualizaciones de estado sobre componentes desmontados.
Otro patrón muy frecuente es la suscripción a eventos globales, como resize de window o keydown en document:
import { useEffect } from "react";
function EventosGlobales() {
useEffect(() => {
const handleResize = () => {
console.log(«Ventana redimensionada»);
};
const handleKeyDown = (event) => {
console.log(«Tecla pulsada», event.key);
};
window.addEventListener(«resize», handleResize);
document.addEventListener(«keydown», handleKeyDown);
return () => {
window.removeEventListener(«resize», handleResize);
document.removeEventListener(«keydown», handleKeyDown);
};
}, []);
return <div>Escuchando eventos globales</div>;
}
La limpieza deja el sistema exactamente como estaba antes de montar el componente. Este espejo entre la configuración y la limpieza es fundamental, sobre todo considerando que en modo estricto de desarrollo React puede ejecutar un ciclo extra de setup → cleanup → setup para detectar errores.
Peticiones HTTP con useEffect y useState: receta paso a paso
Uno de los usos más comunes de useEffect es cargar datos de una API cuando el componente se monta y almacenar el resultado en estado con useState. Hay una “receta” muy práctica que puedes reutilizar casi siempre:
- Arranca el componente en modo “cargando” con un estado tipo
isLoading. - Haz la petición dentro de useEffect justo después de que React pinte el componente.
- Guarda la respuesta en el estado y marca
isLoadingcomofalsecuando termine. - Renderiza condicionalmente un spinner o mensaje de carga mientras
isLoadingseatrue, y los datos cuando ya estén listos.
Ejemplo completo: app que muestra un perrito aleatorio usando la Dog API.
import { useState, useEffect } from "react";
function RandomDog() {
const [imageUrl, setImageUrl] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDog = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(«https://dog.ceo/api/breeds/image/random»);
if (!response.ok) {
throw new Error(«Error HTTP » + response.status);
}
const data = await response.json();
setImageUrl(data.message);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
fetchDog();
}, []);
if (isLoading) {
return <div>Cargando…</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div>
<img src={imageUrl} alt=»Perrito aleatorio» />
</div>
);
}
Fíjate en que se comprueba response.ok para distinguir entre respuesta correcta (códigos 200-299) y errores HTTP. Fetch solo rechaza la promesa por fallos de red, así que si no miras ok puedes pensar que “todo bien” incluso con un 500.
Este mismo patrón se puede adaptar para peticiones que dependen de props. Por ejemplo, si quieres mostrar los datos de un usuario concreto según un userId que llega desde arriba, utilizarías esa prop como dependencia del efecto para refrescar los datos cada vez que cambie.
Peticiones dependientes de props y cancelación segura en desmontaje
Cuando la URL o la identidad de los datos a cargar dependen de una prop (por ejemplo, cargar de nuevo el usuario si cambia props.userId), es importante:
- Especificar correctamente la prop como dependencia del efecto.
- Evitar actualizaciones de estado si el componente se desmonta antes de que termine la petición.
Un patrón sencillo para la cancelación es usar una bandera interna:
import { useEffect, useState } from "react";
import axios from "axios";
function User({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
const result = await axios(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (isMounted) {
setUser(result.data);
}
};
fetchData();
return () => {
isMounted = false;
};
}, [userId]);
if (!user) {
return <div>Cargando…</div>;
}
return (
<div>
<h1>{user.name}</h1>
<p>Correo electrónico: {user.email}</p>
<p>Teléfono: {user.phone}</p>
</div>
);
}
Mientras isMounted sea true, se permite actualizar el estado. Cuando el componente se desmonta, el cleanup del efecto pone esa bandera a false, y aunque la promesa se resuelva más tarde, setUser ya no se ejecutará. Hay alternativas más refinadas (AbortController, librerías de datos, etc.), pero este patrón ilustrativo deja clara la idea.
Reutilizar lógica: múltiples efectos en un mismo componente
Uno de los grandes puntos fuertes de los hooks es que no estás limitado a un solo useEffect por componente. De hecho, suele ser preferible dividir la lógica en varios efectos especializados en lugar de mezclarlo todo en uno enorme.
Piensa en un componente que tiene a la vez un contador, datos remotos y quizá una suscripción. En clases, terminabas con métodos del ciclo de vida llenos de código mezclado: en componentDidMount lanzabas la petición y te suscribías; en componentDidUpdate comprobabas manualmente qué había cambiado; en componentWillUnmount limpiabas ambas cosas.
Con hooks, puedes usar un useEffect para cada “idea” o responsabilidad:
import { useState, useEffect } from "react";
const MiComponente = () => {
const [count, setCount] = useState(0);
const [data, setData] = useState(null);
useEffect(() => {
console.log(«Componente montado»);
}, []);
useEffect(() => {
console.log(`Count ha cambiado a ${count}`);
}, [count]);
useEffect(() => {
console.log(«Data ha cambiado»);
}, [data]);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>Haz clic aquí</button>
</div>
);
};
De esta forma, cada efecto solo se preocupa por lo que le interesa, el código se vuelve más legible y es más fácil añadir o eliminar comportamiento sin romper el resto. React aplicará todos los efectos del componente en el orden en el que los declares.
Optimizar rendimiento: dependencias y efectos que no se repiten de más
Cuando un efecto hace trabajo costoso (consultas a API, cálculos pesados, re-renderizados de terceros…), no querrás que se ejecute en cada actualización del componente. En un componente de clase esto se resolvía a menudo con comparaciones manuales tipo if (prevProps.x !== this.props.x) dentro de componentDidUpdate.
Con hooks, la optimización está integrada en la propia firma de useEffect mediante el array de dependencias. Por ejemplo, si solo quieres que un efecto se dispare cuando cambie count, lo declaras como:
useEffect(() => {
console.log("Ha cambiado count");
}, [count]);
React comparará el antiguo array de dependencias [valorAnteriorDeCount] con el nuevo [valorNuevoDeCount]. Si todos los elementos son iguales, el efecto se salta. Si alguno difiere, se limpia el efecto anterior (si tenía cleanup) y se ejecuta la configuración de nuevo.
Del mismo modo, si quieres un efecto que se ejecute solo una vez para inicializar algo (y opcionalmente limpiarlo al desmontar), usas un array vacío. Pero cuidado: las props y el estado que leas dentro del efecto permanecerán con sus valores iniciales, por lo que este patrón hay que usarlo cuando estás seguro de que tu efecto no depende de nada que cambie.
Para evitar despistes, es muy recomendable activar la regla exhaustive-deps de eslint-plugin-react-hooks. Esta regla analiza el código del efecto y te avisa si falta alguna dependencia, sugiriendo la corrección apropiada. Es una forma muy eficaz de evitar errores sutiles donde se usan valores obsoletos de estado o props.
Problemas típicos con useEffect y cómo evitarlos
Trabajar con efectos es sencillo en teoría, pero en la práctica hay una serie de errores recurrentes que conviene tener siempre en mente para no caer en ellos una y otra vez.
Uno de los más sonados es el bucle infinito de renders. Sucede cuando tu efecto actualiza un estado que forma parte de sus dependencias de una forma que no converge. Por ejemplo, si tienes:
useEffect(() => {
setCount(count + 1);
}, [count]);
En cada render, como count cambia, el efecto vuelve a ejecutarse, y vuelves a actualizar el estado, y así hasta el infinito. En estos casos, o bien te estás confundiendo de sitio (esa actualización no debería vivir en un efecto), o bien necesitas otro tipo de lógica, como un intervalo con cleanup, o usar la forma funcional setCount(c => c + 1) sin poner count en las dependencias.
Otro tema es la sensación de que el efecto “se ejecuta dos veces al montar”. En modo estricto, React en desarrollo hace una especie de “test de estrés” ejecutando setup → cleanup → setup justo al principio. Esto no ocurre en producción, pero pone en evidencia efectos mal planteados que no limpian bien. Si tu lógica de cleanup es simétrica a la de setup, el usuario no debería notar nada raro.
También son frecuentes los efectos que se repiten en cada render a pesar de tener dependencias. Suele ocurrir porque en el array se incluyen objetos o funciones creados en cada render. Como las referencias cambian siempre, React considera que la dependencia también ha cambiado. La solución: declarar esas funciones u objetos dentro del propio efecto o memorizarlos con useCallback/useMemo si realmente hacen falta fuera.
useEffect, sistemas externos y uso avanzado con eventos de efecto
La razón de ser de useEffect es mantener sincronizado tu componente con sistemas externos: sockets, mapas, reproductores de vídeo embebidos, APIs del navegador, etc. El patrón siempre se basa en la idea de “cuando cambien estas dependencias, conéctate de nuevo con los nuevos valores y desconéctate de los antiguos”.
Por ejemplo, un componente ChatRoom que se conecta a una sala concreta en función de serverUrl y roomId debería:
- Conectarse al montar con la primera combinación de
serverUrlyroomId. - Desconectarse de la sala anterior y conectarse a la nueva si el usuario cambia de sala o de servidor.
- Desconectarse definitivamente al desmontar el componente.
Con useEffect y un buen cleanup consigues que cada render tenga su conexión coherente y que React pueda repetir la secuencia setup/cleanup sin que se quede nada colgando.
En escenarios más avanzados, puede que necesites leer el estado y las props más recientes dentro de un efecto sin que eso dispare re-ejecuciones. Ahí entran en juego patrones como los Effect Events (hooks como useEffectEvent en las nuevas recomendaciones), que te permiten mover cierto código no reactivo fuera de la lista de dependencias. Mientras tanto, una aproximación práctica es usar refs o hooks personalizados para aquellos trozos donde el comportamiento reactivo clásico no encaja bien.
En el contexto del renderizado del lado del servidor (SSR), ten en cuenta que los efectos solo corren en el cliente. En el servidor se renderiza el HTML inicial, y una vez hidratada la app en el navegador, entonces sí se disparan los useEffect. Si necesitas mostrar algo distinto solo en cliente, puedes usar un estado como didMount que pase de false a true en un efecto con [] y condicionar el render a partir de ahí.
Dominar useState y useEffect es, al final, cuestión de interiorizar que cada render es una fotografía de tu componente, que los hooks se atan al orden en el que los declaras y que los efectos describen cómo sincronizar esa fotografía con el mundo exterior, incluyendo cómo deshacer lo que haya que deshacer. Con un buen manejo de dependencias, limpieza y separación de efectos por responsabilidades, tus componentes funcionarán de forma mucho más predecible, consumirán menos recursos y te costará bastante menos razonar sobre qué puede estar fallando cuando algo no se comporta como debería.