Deje de sincronizar el estado con useEffect: un patrón más seguro en React
Aprenda por qué usar useEffect para sincronizar el estado derivado provoca condiciones de carrera y renders adicionales, y cómo reemplazarlo con la derivación en tiempo de renderizado y el atributo key.
Tratar useEffect como un mecanismo para mantener el estado sincronizado provoca bucles de renderizado, condiciones de carrera y fallos fantasma en la interfaz de usuario.
Llega a su escritorio un ticket de soporte marcado como urgente. Un cliente indica que, al cambiar entre cuentas en un panel de equipo, el registro de actividades a veces muestra eventos pertenecientes a la cuenta que vio treinta segundos antes.
Analiza el código fuente. No hay nada exótico aquí: sin capa de WebSocket, sin hilos de trabajo, solo una vista React típica de tipo master-detail.
Al probarlo localmente, hace clic en las cuentas una tras otra. Los primeros noventa y nueve cambios funcionan bien. Luego, en el centésimo intento con la limitación de red activada, algo falla visualmente: por un breve instante, el nivel de facturación del usuario seleccionado anteriormente aparece dentro de la tarjeta de perfil del usuario recién seleccionado antes de corregirse.
La raíz de ese problema es un patrón que parece completamente inofensivo:
useEffect(() => {
if (selectedUserId) {
fetchUserData(selectedUserId).then((data) => {
setUserProfile(data);
});
}
}, [selectedUserId]);
Esta costumbre —recurrir a useEffect para mantener el estado interno del componente alineado con las props u otro estado— causa más errores sutiles, problemas visuales y complicaciones estructurales en el código frontend moderno que casi cualquier otro patrón.
1. La falacia del ciclo de vida: por qué los desarrolladores optan por useEffect
Cuando las Hooks llegaron a React 16.8, los ingenieros con experiencia en componentes de clase solían tratar useEffect como un reemplazo directo de componentDidMount, componentDidUpdate y componentWillUnmount combinados en una sola API.
Esa suposición generó verdadera confusión posteriormente.
Los componentes de clase fomentaban un estilo imperativo: cuando cambiaba una propiedad, había que activar manualmente this.setState() dentro de componentDidUpdate para volver a calcular cualquier valor derivado de ella.
Al pasar a los componentes funcionales, muchos desarrolladores mantuvieron esa misma costumbre imperativa, asumiendo básicamente que cada vez que cambiaba alguna propiedad, era su tarea insertar explícitamente la actualización correspondiente en una variable de estado local.
El problema es que React es fundamentalmente declarativo y está orientado al estado. Escribir un useEffect cuyo único propósito es actualizar otra variable de estado local obliga efectivamente a React a realizar dos pasos completos de renderizado en lugar de uno.
Aquí está la secuencia que se produce:
- React renderiza el componente utilizando las nuevas propiedades junto con el estado aún desactualizado.
setState().Tomemos este ejemplo:
function UserBillingSummary({
plan,
addonCount
}: {
plan: string;
addonCount: number;
}) {
const [totalCost, setTotalCost] = useState(0);
useEffect(() => {
const base = plan === 'enterprise' ? 499 : 99;
setTotalCost(base + addonCount * 25);
}, [plan, addonCount]); return <div>Total: ${totalCost} / month</div>;
}
Aquí no hay realmente necesidad alguna de una variable de estado separada; totalCost puede obtenerse íntegramente a partir de plan y addonCount.
En otras palabras, el componente realiza un trabajo adicional innecesario solo para llegar a un valor que ya era calculable durante la renderización inicial.
Durante ese frame intermedio, los usuarios pueden ver brevemente números inconsistentes en la pantalla. Y si alguna lógica de diseño depende de ese valor calculado, el navegador también podría tener que volver a realizar tareas de diseño y dibujo que no debería haber tenido que repetir.
2. El efecto dominó: cadenas de dependencias en cascada
Este costo por renderizado doble empeora considerablemente cuando varios efectos comienzan a depender de los resultados del otro.
Imagínese un panel de filtrado de múltiples pasos dentro de un panel de análisis:
function AnalyticsFilters({
organizationId
}: {
organizationId: string;
}) {
const [teams, setTeams] = useState<Team[]>([]);
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
useEffect(() => {
fetchTeams(organizationId).then((res) => {
setTeams(res);
setSelectedTeamId(res[0]?.id || '');
});
}, [organizationId]); useEffect(() => {
if (selectedTeamId) {
fetchProjects(selectedTeamId).then((res) => {
setProjects(res);
setSelectedProjectId(res[0]?.id || '');
});
}
}, [selectedTeamId]); useEffect(() => {
if (selectedProjectId) {
logAnalyticsFilterChange(selectedProjectId);
}
}, [selectedProjectId]); return (
<div className="filter-bar">
{/* Filter UI */}
</div>
);
}
Observe lo que sucede en el momento en que cambia organizationId:
- React vuelve a renderizar utilizando el nuevo
organizationId. - El primer efecto obtiene la lista de equipos y luego actualiza tanto
teamscomoselectedTeamId. - React vuelve a renderizar.
- Un segundo efecto detecta el valor actualizado de
selectedTeamIdy obtiene los proyectos relacionados. - React vuelve a renderizar una vez más.
- Un tercer efecto toma el nuevo valor de
selectedProjectIdy registra el cambio.
Lo que comenzó como una sola actualización de propiedades ahora se ha convertido en una cadena de cambios de estado y ejecuciones de efectos.
A medida que una aplicación crece, cadenas como esta se vuelven realmente difíciles de rastrear. Si las respuestas de la red llegan fuera de orden, o si una de ellas devuelve valores vacíos debido a un problema de permisos u otro caso límite, la interfaz puede quedar en un estado inconsistente sin que se genere ningún error evidente.
El verdadero problema no es solo el aumento en la cantidad de procesamientos: es que el componente se ha convertido silenciosamente en una máquina de estados asíncrona en miniatura que nadie diseñó intencionalmente como tal.
3. El fantasma de las condiciones de carrera asíncronas
Las llamadas asíncronas no gestionadas dentro de useEffect son otra fuente frecuente de datos fantasma que aparecen en las aplicaciones de página única.
Imagínese a un agente de soporte haciendo clic rápidamente en diferentes filas de tickets en una tabla:
function TicketDetailView({
ticketId
}: {
ticketId: string;
}) {
const [ticket, setTicket] = useState<TicketData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true); api.getTicket(ticketId).then((data) => {
setTicket(data);
setLoading(false);
});
}, [ticketId]); if (loading) return <div>Loading ticket...</div>; return <TicketDetails ticket={ticket} />;
}
Esta es la secuencia de eventos que daña a este componente:
- El agente hace clic en el Ticket #101. Se envía la Solicitud A.
- Antes de que se resuelva A, el agente hace clic en el Ticket #102. Se envía la Solicitud B.
- La Solicitud B se resuelve primero, por lo que la interfaz ahora muestra el Ticket #102.
- Finalmente se resuelve la Solicitud A y se llama a
setTicket(ticket101). - La barra lateral sigue resaltando el Ticket #102, pero el panel de detalles ahora muestra el Ticket #101 en su lugar.
React no es el culpable aquí. El verdadero problema es que el componente permite que una solicitud obsoleta sobrescriba el estado después de que el usuario ya haya navegado a otra parte.
Si estás obteniendo datos dentro de un efecto sin ninguna biblioteca auxiliar, debes protegerte contra esto con una limpieza manual:
useEffect(() => {
let isCurrent = true;
setLoading(true); api.getTicket(ticketId)
.then((data) => {
if (isCurrent) {
setTicket(data);
setLoading(false);
}
})
.catch((err) => {
if (isCurrent) {
handleTicketError(err);
}
}); return () => {
isCurrent = false;
};
}, [ticketId]);
Una opción mejor, cuando su cliente API lo soporta, es AbortController. En lugar de simplemente descartar una respuesta tardía, se cancela la solicitud en curso antes de que pueda resolverse.
4. La alternativa adecuada: estado derivado durante la renderización
A menudo, la solución más sencilla para los errores de sincronización es evitar sincronizar el estado desde un principio.
En muchos casos en los que los desarrolladores recurren instintivamente a useState junto con useEffect, el valor que intentan almacenar ya existe en algún lugar de los props actuales o del estado del padre.
En lugar de duplicar ese valor en el estado local, se puede calcular directamente mientras se realiza la renderización.
Aquí está el patrón problemático:
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const [discountPercent, setDiscountPercent] = useState(0);
const [subtotal, setSubtotal] = useState(0);
const [finalTotal, setFinalTotal] = useState(0);
useEffect(() => {
const rawSum = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
); setSubtotal(rawSum);
}, [items]); useEffect(() => {
const discount = calculateDiscount(discountCode);
setDiscountPercent(discount);
}, [discountCode]); useEffect(() => {
setFinalTotal(
subtotal - subtotal * (discountPercent / 100)
);
}, [subtotal, discountPercent]); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
Ahora comparemos eso con una versión basada en estado derivado:
function OrderSummary({
items,
discountCode
}: OrderSummaryProps) {
const subtotal = items.reduce(
(acc, item) => acc + item.price * item.quantity,
0
);
const discountPercent = calculateDiscount(discountCode); const finalTotal =
subtotal - subtotal * (discountPercent / 100); return (
<SummaryView
subtotal={subtotal}
total={finalTotal}
/>
);
}
El contraste es importante. No existe un estado duplicado, ni ningún mecanismo que mantenga sincronizados los valores, y no hay ventana en la cual finalTotal pueda desalinearse de subtotal y discountPercent.
Las propias props siguen siendo la única fuente de verdad en todo momento.
Cuando un cálculo es realmente costoso, useMemo permite almacenar en caché el resultado entre renders:
const filteredTransactions = useMemo(() => {
return rawTransactions.filter((tx) => {
return (
tx.amount >= minThreshold &&
tx.category === activeCategory
);
});
}, [rawTransactions, minThreshold, activeCategory]);
El punto clave es que useMemo existe únicamente para memorizar un cálculo; no está diseñado para mantener sincronizados dos estados separados.
5. Restablecer el estado de forma declarativa con la prop key
Una trampa relacionada surge cuando un formulario editable necesita restablecer sus campos cada vez que cambia la entidad que se está editando.
El instinto típico es el siguiente:
function EditUserModal({
user
}: {
user: UserData;
}) {
const [name, setName] = useState(user.name);
const [role, setRole] = useState(user.role);
useEffect(() => {
setName(user.name);
setRole(user.role);
}, [user.id]); return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
Este enfoque puede generar un destello visible, ya que los datos del registro anterior permanecen en la pantalla durante una renderización antes de que se active el efecto y se actualicen los campos de entrada.
Peor aún, puede sobrescribir silenciosamente lo que el usuario estaba escribiendo si llegan nuevos datos mientras se edita.
React ya cuenta con una solución declarativa integrada para esto: la propiedad key.
En el componente padre:
function UserAdminPage() {
const [selectedUser, setSelectedUser] =
useState<UserData | null>(null);
return (
<div>
<UserList onSelectUser={setSelectedUser} /> {selectedUser && (
<EditUserForm
key={selectedUser.id}
initialUser={selectedUser}
/>
)}
</div>
);
}
El estado local del componente hijo se mantiene entonces simple, sin nada que reconciliar:
function EditUserForm({
initialUser
}: {
initialUser: UserData;
}) {
const [name, setName] = useState(initialUser.name);
const [role, setRole] = useState(initialUser.role);
return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/> <select
value={role}
onChange={(e) => setRole(e.target.value)}
/>
</form>
);
}
Cuando la key cambia de user-1 a user-2, React no intenta actualizar el componente existente; lo descarta y monta una instancia completamente nueva, con el estado inicializado con los nuevos datos del usuario.
No se necesita ningún efecto de sincronización en absoluto.
6. Donde realmente pertenece el código: manejadores de eventos vs. efectos
Un modelo mental útil es el siguiente: los efectos existen para mantener tu componente sincronizado con algo fuera de React, mientras que los manejadores de eventos existen para responder a una acción realizada por el usuario.
Imagina que necesitas enviar un evento de análisis y mostrar una notificación de confirmación cada vez que alguien hace clic en “Enviar pedido”.
Una forma de escribir esto es:
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}
}, [submitted, orderId]); return (
<button onClick={() => setSubmitted(true)}>
Place Order
</button>
);
}
El problema es que esto separa el disparador de la acción: el clic establece una bandera, y un efecto reacciona a esa bandera más tarde.
Un enfoque más limpio mantiene todo dentro del propio manejador:
function CheckoutButton({
orderId
}: {
orderId: string;
}) {
const handlePlaceOrder = async () => {
await submitOrderApi(orderId);
analytics.track('order_submitted', {
orderId
}); showToast('Order placed successfully!');
}; return (
<button onClick={handlePlaceOrder}>
Place Order
</button>
);
}
Ahora la causalidad es explícita: el usuario hace clic, se envía el pedido y las acciones posteriores se ejecutan de inmediato como parte del mismo evento. No hay un cambio de estado intermedio que permita detectar y reaccionar ante algún efecto separado.
7. ¿Cuándo está realmente justificado usar useEffect?
Nada de esto significa que useEffect en sí mismo sea defectuoso.
El problema surge cuando se trata como una herramienta universal para transferir datos entre diferentes partes del estado de React.
Su función real es mantener tu componente sincronizado con algo que existe fuera del propio modelo de renderizado de React.
Ese “algo fuera de React” suele pertenecer a categorías como:
- APIs nativas del navegador, como
window.addEventListener,IntersectionObserveromatchMedia
document.titleRastrear el ancho de la ventana del navegador al cambiar su tamaño es un buen ejemplo de un efecto legítimo:
function useWindowWidth() {
const [width, setWidth] = useState(
() => window.innerWidth
);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
}; window.addEventListener(
'resize',
handleResize
); return () => {
window.removeEventListener(
'resize',
handleResize
);
};
}, []); return width;
}
En este caso, el efecto consiste en hacer algo que la renderización por sí sola no puede lograr: se establece una suscripción a un evento del navegador y se cancela al finalizar el proceso.
Ese es precisamente el tipo de tarea para la cual fue diseñado useEffect.
Las reglas arquitectónicas que sigo ahora
Cuando un panel de control o aplicación basada en React comienza a ser lenta, errática o está llena de errores de temporización difíciles de reproducir, el primer lugar que merece ser revisado es cómo se está utilizando useEffect en toda la base de código.
Cuatro principios rectores suelen resolver la mayoría de los problemas.
1. Calcular valores durante el renderizado.
Todo lo que pueda derivarse de las propiedades o del estado existente debe calcularse directamente en el cuerpo del renderizado. Utiliza useMemo solo cuando ese cálculo sea realmente costoso.
2. Mantener la lógica desencadenada por el usuario dentro de los manejadores de eventos. Cuando algo ocurre debido a un clic, tecla presionada, selección o envío, esa lógica debe encontrarse justo al lado del evento que la provocó, y no dispersa en un efecto separado.
3. Restablecer el estado de manera limpia con la propiedad key.
Al cambiar entre entidades, se debe generar una instancia de componente completamente nueva; deja que React vuelva a montar el componente en lugar de sincronizar manualmente cada campo individual.
4. Reserve useEffect para una sincronización externa real.
Los eventos del navegador, las suscripciones, las conexiones WebSocket y las integraciones imperativas con bibliotecas son el ámbito adecuado para los efectos.
No se trata de eliminar por completo useEffect de su código.
Sino de dejar de usarlo como un canal informal para transferir valores entre diferentes partes del estado de React.
Cuando los valores derivados se tratan como tales, las acciones del usuario se manejan como eventos y solo los sistemas externos reales se conectan a través de efectos, los componentes de React se vuelven mucho más fáciles de comprender.
Además, una gran parte de los bugs misteriosos que solo aparecen después de docenas de clics, con una conexión de red lenta o exclusivamente en entornos de producción, se vuelven mucho más fáciles de prevenir desde el principio.
Lecturas relacionadas
- Creando un modelo mental para React: Reconciliación, estado y hooks — Aprende el razonamiento detrás de los conceptos fundamentales de React: reconciliación, componentes, props, estado y hooks, para desarrollar intuición en lugar de memorizar APIs.
- Un kit de gancho personalizados reutilizables para cada proyecto nuevo en React — Explora un conjunto seleccionado de gancho personalizados para React que abarcan almacenamiento, desaceleración de eventos, clics y obtención de datos, y que eliminan el código repetitivo en los proyectos nuevos.