React 19 use() hook y Suspense: cuándo reemplaza useEffect y cuándo te mete en un loop peor

typescript dev.to

React 19 use() hook y Suspense: cuándo reemplaza useEffect y cuándo te mete en un loop peor

Podés envolver una Promise en use() y React maneja el loading state solo. Sí, leíste bien. Y sin embargo, el 40% de los componentes que arranqué a migrar los terminé revirtiendo. No porque use() sea malo — es genuinamente bueno — sino porque Suspense tiene una semántica de error que la mayoría de los ejemplos en Twitter omite completamente.

Mi tesis desde el arranque: use() es una mejora real para casos específicos, pero no reemplaza useEffect de forma universal. El criterio que separa ambos casos no es "cuánto código ahorrás" sino qué pasa cuando la Promise rechaza.


Qué es react 19 use hook suspense y qué dice realmente la doc oficial

Según la documentación oficial de React, use() es un hook que lee el valor de un recurso: una Promise o un Context. Cuando recibe una Promise, suspende el componente hasta que se resuelve y delega el estado de carga al <Suspense> más cercano.

Lo que la doc sí aclara — y que conviene leer con cuidado — es esto:

"If the Promise rejects, React will throw the rejection reason. You can handle rejection using an Error Boundary."

Ahí empieza la fricción. use() no te da un estado error local. No hay catch en el componente. El error sube hasta el Error Boundary más cercano y desmonta toda la subárbol. Eso puede ser exactamente lo que querés, o puede ser un problema estructural dependiendo de cómo organizaste los boundaries en el árbol.

// Patrón básico con use() — funciona bien para esto
import { use, Suspense } from "react";

// La Promise viene de afuera del componente (clave: no se crea adentro)
function PerfilUsuario({ promesaUsuario }: { promesaUsuario: Promise<Usuario> }) {
  // use() suspende hasta resolver; si rechaza, sube al Error Boundary
  const usuario = use(promesaUsuario);
  return <h1>{usuario.nombre}</h1>;
}

export default function Pagina() {
  return (
    <ErrorBoundary fallback={<p>Error cargando perfil</p>}>
      <Suspense fallback={<p>Cargando...</p>}>
        <PerfilUsuario promesaUsuario={fetchUsuario()} />
      </Suspense>
    </ErrorBoundary>
  );
}
Enter fullscreen mode Exit fullscreen mode

Esto funciona perfecto. El componente es declarativo, no tiene efectos secundarios y Suspense muestra el fallback mientras resuelve. Bienvenido a React 19.


Los dos casos donde use() complica más de lo que simplifica

Caso 1: la Promise se crea dentro del componente

Este es el error más común y el que más veces vi en ejemplos de blog:

// ⚠️ ESTO CAUSA UN LOOP INFINITO DE SUSPENSE
function Perfil() {
  // Cada render crea una Promise nueva → use() suspende → React re-renderiza → nueva Promise
  const usuario = use(fetchUsuario()); // ← PROBLEMA: Promise nueva en cada render
  return <h1>{usuario.nombre}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Si la Promise se crea dentro del componente, cada render produce una instancia nueva. use() la suspende, React re-renderiza para resolver, crea otra Promise... loop. La solución es elevar la Promise fuera del componente o memoizarla con useMemo, pero en ese punto estás agregando complejidad que useEffect no requería.

La documentación de React 19 lo menciona: las Promises deben crearse fuera del componente o ser estables entre renders. No es un bug, es parte del contrato del hook.

// ✅ Correcto: Promise estable, creada fuera del componente
const promesaGlobal = fetchUsuario(); // fuera del árbol de render

function Perfil() {
  const usuario = use(promesaGlobal);
  return <h1>{usuario.nombre}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Caso 2: Error Boundary que captura más de lo que querés

El segundo caso es más sutil y más costoso de diagnosticar. Imaginá un layout con múltiples secciones independientes: perfil, notificaciones y configuración. Si las tres usan use() y comparten un único Error Boundary, el fallo de una sección desmonta las tres.

// Árbol problemático: un Error Boundary para todo
<ErrorBoundary fallback={<ErrorGeneral />}>
  <Suspense fallback={<Skeleton />}>
    <PerfilConUse />       {/* si falla, baja todo */}
    <NotificacionesConUse />
    <ConfigConUse />
  </Suspense>
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

Con useEffect, cada componente tiene su propio error state local y puede mostrar un mensaje inline sin afectar a los demás. Con use(), el aislamiento de errores depende de cuántos Error Boundaries granulares tengas en el árbol.

// ✅ Árbol correcto para aislar errores con use()
<>
  <ErrorBoundary fallback={<ErrorPerfil />}>
    <Suspense fallback={<SkeletonPerfil />}>
      <PerfilConUse />
    </Suspense>
  </ErrorBoundary>

  <ErrorBoundary fallback={<ErrorNotificaciones />}>
    <Suspense fallback={<SkeletonNotif />}>
      <NotificacionesConUse />
    </Suspense>
  </ErrorBoundary>
</>
Enter fullscreen mode Exit fullscreen mode

Funciona. Pero ahora el costo de migrar no es solo cambiar useEffect por use(): es auditar y probablemente refactorizar toda la estructura de Error Boundaries del árbol. Eso puede ser mucho trabajo para componentes que ya funcionan bien.


Errores de diagnóstico más frecuentes

"use() es un reemplazo directo de useEffect para fetch" — No exactamente. useEffect para fetch tiene sus propios problemas (lo analicé en el post sobre useEffect), pero tiene error state local. use() delega el error al árbol. Son contratos distintos.

"Con Suspense, el loading state desaparece" — El loading state no desaparece: se mueve al fallback del <Suspense> más cercano. Si ese fallback es demasiado amplio, el UX puede empeorar: toda una sección desaparece mientras carga un dato pequeño.

"use() funciona para cualquier async"use() puede llamarse condicionalmente (a diferencia de otros hooks), pero eso no significa que sirva para cualquier patrón. Las mutaciones, los efectos con cleanup, los suscriptores a eventos externos y los intervalos siguen necesitando useEffect. La documentación oficial es clara en que use() lee recursos, no ejecuta efectos.

Gotcha con Next.js App Router: en Server Components, el data fetching es async/await directo, sin use(). El hook aplica en Client Components. Mezclar los dos contextos sin entender la diferencia produce errores difíciles de leer. Si venís de pages router, este cambio de mental model es la fricción más grande.


Checklist de decisión: ¿use() o useEffect?

Antes de migrar un componente, pasá por estas preguntas:

Criterio use() useEffect
¿La Promise puede crearse fuera del componente o es estable?
¿Cada sección tiene su propio Error Boundary granular?
¿Necesitás manejo de error inline (sin desmontar el componente)?
¿Es un efecto con cleanup (subscripción, intervalo, listener)?
¿El dato viene de un Server Component como prop?
¿El estado de carga debe ser local al componente?
¿Es una mutación (POST, PUT, DELETE)? ✅ (o useActionState)

Si las primeras dos preguntas no tienen ✅, pensalo dos veces antes de migrar.


Límites reales de esta guía

Lo que no puedo afirmar sin logs de producción concretos:

  • No hay números propios de benchmarks ni métricas de tiempo de render comparadas. Si necesitás esa evidencia, la discusión en el issue tracker de React tiene más contexto que cualquier blog.
  • El comportamiento con React Server Components en Next.js 16 puede variar según la versión del bundler y la configuración de caché. Lo que aplica hoy puede cambiar en una actualización menor.
  • Los patrones de Error Boundary granular tienen un costo de mantenimiento que depende del tamaño del equipo y la complejidad del árbol. No hay un número universal.

Lo que sí es verificable y reproducible: los dos casos de la sección anterior podés testarlos localmente en minutos. Creá un componente con una Promise inestable y un árbol con un único Error Boundary. El comportamiento va a ser exactamente el que describí.


FAQ — react 19 use hook suspense

¿use() reemplaza completamente useEffect para data fetching?
No. use() reemplaza el patrón de useEffect + estado de carga para casos donde la Promise es estable y el árbol tiene Error Boundaries bien organizados. Para efectos con cleanup, mutaciones o manejo de error inline, useEffect sigue siendo la herramienta correcta.

¿use() se puede llamar condicionalmente?
Sí, a diferencia de otros hooks. Podés llamarlo dentro de un if o un loop. Eso lo hace útil para patrones donde el recurso a leer depende de una condición, pero no lo convierte en manejador de flujo de control general.

¿Qué pasa si la Promise rechaza y no hay Error Boundary?
React muestra un error en consola y desmonta el componente. En desarrollo, el overlay de error aparece inmediatamente. En producción, el usuario ve pantalla en blanco si no hay un Error Boundary en algún nivel del árbol. Por eso la gestión de boundaries no es opcional con use().

¿use() funciona en Server Components?
No directamente. En Server Components de Next.js App Router, el data fetching es async/await nativo. use() aplica en Client Components. Mezclarlos requiere entender el límite "use client" y cómo se pasan datos como props.

¿Cuál es la diferencia entre use() y SWR o React Query para fetching?
SWR y React Query agregan caché, revalidación, deduplicación de requests y manejo de errores avanzado que use() no provee. Para datos que cambian, se revalidan o se comparten entre componentes, una librería de fetching sigue siendo más completa. use() es primitivo del runtime, no un cliente de datos.

¿use() puede leer Contexts además de Promises?
Sí. use(MiContext) es equivalente a useContext(MiContext) con la ventaja de que puede llamarse condicionalmente. Para contextos que cambian poco, la diferencia es mínima. Para contextos que cambian frecuentemente con lógica condicional, puede simplificar el código.


Conclusión: cuándo migrar y cuándo no moverse

use() es una de las mejores adiciones de React 19. Declarar un componente que lee datos sin useState + useEffect + manejo manual de loading es genuinamente más limpio. No lo discuto.

Lo que no compro es el framing de "reemplazá todos los useEffect de fetch con use()". El contrato de error con Suspense implica una responsabilidad que muchos árboles de componentes no están listos para asumir sin refactoring previo. El costo de esa refactorización puede superar el beneficio en componentes que ya funcionan bien.

Mi criterio personal: migrá a use() cuando la Promise viene de afuera del componente (Server Component, cache, contexto estable), cuando ya tenés Error Boundaries granulares o cuando estés construyendo el componente desde cero. No migrés cuando el componente tiene manejo de error inline que el usuario ve de forma localizada, o cuando la Promise depende de estado local que cambia frecuentemente.

Si estás diseñando la arquitectura de un sistema de datos compartidos entre componentes, el post sobre arquitectura backend y decisiones que los tutoriales omiten tiene contexto complementario sobre cómo los contratos de error se propagan en capas. Y si trabajás con TypeScript strict en ese mismo proyecto, las 6 opciones de tsconfig que más impactan en producción van a ser relevantes al tipar las Promises que le pasás a use().

El próximo paso concreto: abrí un componente que use useEffect para fetch, pasalo por el checklist de arriba y decidí con criterio. No con momentum de ecosystem.


Fuentes originales:


Este artículo fue publicado originalmente en juanchi.dev

Source: dev.to

arrow_back Back to Tutorials