Inicio / Artículos / Deje de sincronizar el estado con useEffect: un patrón más seguro en React

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.

2523 palabras

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:

  1. React renderiza el componente utilizando las nuevas propiedades junto con el estado aún desactualizado.
  • Esa renderización se guarda en el DOM y el navegador la dibuja.
  • El efecto se dispara y llama a setState().
  • React coloca en cola una segunda renderización que refleja el estado actualizado.
  • 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:

    1. React vuelve a renderizar utilizando el nuevo organizationId.
    2. El primer efecto obtiene la lista de equipos y luego actualiza tanto teams como selectedTeamId.
    3. React vuelve a renderizar.
    4. Un segundo efecto detecta el valor actualizado de selectedTeamId y obtiene los proyectos relacionados.
    5. React vuelve a renderizar una vez más.
    6. Un tercer efecto toma el nuevo valor de selectedProjectId y 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:

    1. El agente hace clic en el Ticket #101. Se envía la Solicitud A.
    2. Antes de que se resuelva A, el agente hace clic en el Ticket #102. Se envía la Solicitud B.
    3. La Solicitud B se resuelve primero, por lo que la interfaz ahora muestra el Ticket #102.
    4. Finalmente se resuelve la Solicitud A y se llama a setTicket(ticket101).
    5. 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, IntersectionObserver o matchMedia
  • Bibliotecas imperativas de terceros, como Mapbox, Chart.js o SDKs para reproductores de video
  • Conexiones en tiempo real como WebSockets o Server-Sent Events
  • Manipulación directa del DOM, como actualizar document.title
  • Rastrear 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

  • Habilitar el soporte offline en aplicaciones web con Service Workers — Aprenda cómo utilizar Service Workers y la API Cache para que un sitio web se cargue al instante y siga funcionando incluso sin conexión a Internet.
  • Explicación del renderizado en React: actualizaciones de estado a píxeles en la pantalla — Aprenda cómo las fases de renderizado, reconciliación y confirmación de React se conectan con el proceso de layout, pintado y composición del navegador para generar píxeles.
  • Arreglar condiciones de carrera: por qué el atraso no puede solucionar problemas en interfaces de búsqueda — Aprenda por qué el atraso por sí solo no puede evitar que respuestas obsoletas de la API sobrescriban el estado actual de la interfaz, y explore cuatro soluciones prácticas para garantizar el orden de las solicitudes.
  • Por qué las callbacks de useEffect en React nunca deben ser funciones asincrónicas — Aprenda por qué devolver una función asincrónica desde useEffect rompe el contrato de limpieza de React, y conozca cuatro patrones correctos para manejar la lógica asincrónica de forma segura.