tutoriales.com

React Concurrent Mode y Suspense: Renderizado No Bloqueante y Carga de Datos Avanzada 🚀

Descubre cómo React Concurrent Mode y Suspense transforman la experiencia de usuario al permitir renderizados no bloqueantes y una gestión avanzada de la carga de datos. Este tutorial te guiará a través de los conceptos clave y la implementación práctica para construir aplicaciones React más receptivas.

Avanzado25 min de lectura9 views
Reportar error

React ha evolucionado constantemente para ofrecer mejores experiencias de usuario y un desarrollo más eficiente. Una de las innovaciones más significativas es el Concurrent Mode (ahora parte del modelo de renderizado concurrente por defecto en React 18+) y Suspense. Estas características abren la puerta a un nuevo paradigma de construcción de interfaces, donde el renderizado puede ser pausado, reanudado y priorizado, y la carga de datos se integra de forma nativa en la UI.

Tradicionalmente, React renderizaba de forma síncrona, lo que podía llevar a bloqueos de la UI en operaciones costosas o al cargar datos. Con el renderizado concurrente y Suspense, React puede trabajar en múltiples versiones de la UI al mismo tiempo, interrumpiendo y priorizando actualizaciones para mantener la aplicación fluida y receptiva. ¡Prepárate para llevar tus aplicaciones React al siguiente nivel! ✨


¿Qué es el Renderizado Concurrente en React? 🤔

El renderizado concurrente es una característica interna de React que permite a la librería trabajar en múltiples tareas de renderizado a la vez y decidir cuál de ellas tiene la prioridad más alta. Esto significa que React puede interrumpir una actualización de la UI de menor prioridad (como una animación compleja o una carga de datos) para responder a una interacción de usuario de alta prioridad (como la entrada de texto).

En versiones anteriores a React 18, React realizaba el renderizado de forma completamente síncrona. Una vez que comenzaba un renderizado, no se podía detener. Esto podía llevar a:

  • UI bloqueda: La interfaz se volvía ininteractiva durante operaciones intensivas de CPU.
  • Estados de carga torpes: Necesidad de gestionar manualmente isLoading estados por todas partes.
  • Saltos de contenido: La UI cambiaba bruscamente cuando los datos finalmente estaban disponibles.

El renderizado concurrente aborda estos problemas introduciendo la capacidad de React para:

  • Interrumpir y reanudar: Pausar el trabajo de renderizado en curso y reanudarlo más tarde.
  • Priorizar actualizaciones: Dar preferencia a las actualizaciones más importantes (e.g., input del usuario) sobre las menos importantes (e.g., actualización de una lista grande).
  • Renderizar en segundo plano: Preparar nuevas versiones de la UI sin bloquear el hilo principal, mostrando la UI antigua hasta que la nueva esté lista.
💡 Consejo: A partir de React 18, el renderizado concurrente está habilitado por defecto cuando usas createRoot. Esto significa que muchas de las mejoras de rendimiento y la base para Suspense ya están a tu disposición sin configuración adicional.

Diferencia entre Renderizado Síncrono y Concurrente

Veamos una tabla comparativa para entender mejor la diferencia fundamental:

CaracterísticaRenderizado Síncrono (React < 18)Renderizado Concurrente (React 18+)
---------
Modo de OperaciónBloqueanteNo bloqueante, interruptible
PriorizaciónNo disponibleSí, puede priorizar actualizaciones
---------
Experiencia UIPuede congelarse durante tareas intensasMás fluida y receptiva
Estados de CargaGestión manual y a menudo complejaMejor integración con Suspense para UI de carga
---------
Actualizaciones UIInmediatas, pueden ser bruscasTransiciones suaves, actualizaciones en segundo plano
Síncrono Concurrente Inicio Renderizado Tarea Larga UI Bloqueada Renderizado Completo Inicio Renderizado Pausa por Prioridad Tarea Prioritaria Reanudar Tarea Larga Renderizado Completo

Suspense para la Carga de Datos y Recursos 🎯

Suspense es una característica clave que se integra con el renderizado concurrente. Permite a tus componentes "esperar" a que algo esté listo (como la carga de datos o la importación de un componente por lazy loading) antes de renderizarse, mostrando un fallback de carga mientras tanto. Esto elimina la necesidad de if (isLoading) o if (!data) en cada componente que necesita datos asíncronos.

Con Suspense, puedes definir una interfaz de usuario de carga declarativamente, en cualquier nivel de tu árbol de componentes. Cuando un componente hijo suspende (por ejemplo, porque está esperando datos), el Suspense más cercano en el árbol renderizará su fallback prop.

Cómo funciona Suspense

  1. Envolver Componentes: Envuelve los componentes que pueden "suspender" (es decir, que pueden tardar en cargarse o en obtener datos) con <Suspense fallback={...}>.
  2. Lanzar Promesas: Los componentes hijos, al encontrarse con una promesa pendiente (como una llamada fetch o la importación dinámica de un componente), la "lanzan" (throw). React intercepta esta promesa.
  3. Mostrar Fallback: El <Suspense> más cercano atrapa la promesa y renderiza su fallback (por ejemplo, un spinner de carga).
  4. Re-renderizar al Resolver: Una vez que la promesa se resuelve, React reintenta renderizar el componente hijo, que ahora tiene los datos o recursos necesarios.
🔥 Importante: Suspense por sí solo no sabe cómo cargar datos. Necesita una integración con una biblioteca de carga de datos (como React Query, SWR, o una implementación personalizada que siga el "Suspense data fetching protocol") para funcionar correctamente con datos asíncronos.

Ejemplo Básico de Suspense con React.lazy

React.lazy es una de las primeras APIs que aprovechan Suspense. Permite cargar componentes de forma dinámica solo cuando son necesarios, lo que reduce el tamaño inicial del bundle de JavaScript.

import React, { Suspense } from 'react';

const LazyComponent = React.lazy(() => import('./LazyComponent'));

function App() {
  return (
    <div>
      <h1>Mi Aplicación</h1>
      <Suspense fallback={<div>Cargando componente...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}

export default App;

En este ejemplo, LazyComponent solo se descargará y renderizará cuando sea necesario. Mientras tanto, se mostrará el mensaje "Cargando componente...".


Transiciones con useTransition y useDeferredValue 🚀

El renderizado concurrente introduce dos nuevos Hooks para gestionar la priorización de actualizaciones de la UI: useTransition y useDeferredValue.

useTransition: Manteniendo la UI Responsiva

useTransition te permite marcar ciertas actualizaciones de estado como "transiciones", es decir, actualizaciones de baja prioridad que React puede interrumpir si surge una actualización más urgente. Esto es ideal para evitar que la UI se congele cuando se realizan acciones que no necesitan una retroalimentación instantánea, como filtrar una lista grande o navegar a una nueva página compleja.

Este Hook devuelve un array con dos elementos:

  1. isPending: Un booleano que indica si la transición está actualmente pendiente.
  2. startTransition: Una función que envuelve la actualización de estado de baja prioridad.
import React, { useState, useTransition } from 'react';

function FilterableList() {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleInputChange = (e) => {
    setInputValue(e.target.value);
    // Envuelve la actualización de searchQuery en startTransition
    startTransition(() => {
      setSearchQuery(e.target.value);
    });
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleInputChange} />
      {isPending && <div style={{ color: 'gray' }}>Actualizando resultados...</div>}
      <p>Buscando: {searchQuery}</p>
      {/* Aquí iría la lógica para renderizar la lista filtrada por searchQuery */}
      {/* ... */}
    </div>
  );
}

export default FilterableList;

En este ejemplo:

  • El inputValue se actualiza inmediatamente (alta prioridad), lo que mantiene el campo de entrada responsivo.
  • La actualización de searchQuery (que podría desencadenar un renderizado costoso de una lista grande) se envuelve en startTransition. Esto permite a React diferir esta actualización y renderizar el inputValue primero. Si el usuario sigue escribiendo, React puede descartar el renderizado de searchQuery anterior y comenzar uno nuevo con el último valor.
📌 Nota: useTransition es perfecto para **interacciones donde el usuario espera una respuesta visual inmediata en una parte de la UI, mientras que otra parte puede tardar un poco más en actualizarse**.

useDeferredValue: Retrasando la Actualización de un Valor 🕰️

useDeferredValue es similar a useTransition, pero en lugar de envolver una función de actualización de estado, te permite diferir el valor de una prop o estado. Esto es útil cuando tienes un valor (como un searchQuery) que se actualiza con frecuencia, y quieres que las actualizaciones de la UI que dependen de ese valor sean de baja prioridad.

useDeferredValue devuelve una versión diferida de su valor de entrada. Esto significa que si el valor original cambia rápidamente, el valor diferido solo se actualizará una vez que el hilo principal esté libre, evitando bloqueos de la UI.

import React, { useState, useDeferredValue } from 'react';

function SearchResults({ query }) {
  // Componente que realiza una operación costosa con la query
  const results = /* lógica costosa para filtrar o buscar */ [];
  // Simulación de una operación costosa
  for (let i = 0; i < 50000000; i++) {
    // Esto simula un bloqueo de la UI si no se difiere
  }
  return (
    <div>
      <h3>Resultados para: {query}</h3>
      {results.length === 0 ? <p>No hay resultados.</p> : <ul>{/* ... */}</ul>}
    </div>
  );
}

function AppWithDeferredValue() {
  const [input, setInput] = useState('');
  const deferredInput = useDeferredValue(input, { timeoutMs: 500 }); // Opcional: tiempo de espera

  const handleChange = (e) => {
    setInput(e.target.value);
  };

  return (
    <div>
      <input type="text" value={input} onChange={handleChange} />
      <div>Input actual: {input}</div>
      <Suspense fallback={<div>Cargando resultados diferidos...</div>}>
        <SearchResults query={deferredInput} />
      </Suspense>
    </div>
  );
}

export default AppWithDeferredValue;

En este ejemplo, deferredInput solo se actualizará cuando React determine que tiene tiempo para hacerlo, permitiendo que el <input> se mantenga responsivo incluso si SearchResults es un componente de renderizado costoso. El usuario verá su entrada en el campo de texto de inmediato, mientras que los resultados de búsqueda se actualizarán con un ligero retraso, pero sin bloquear la UI.

⚠️ Advertencia: useDeferredValue puede ser más sencillo de usar en algunos casos que useTransition, ya que no requiere envolver una actualización de estado explícitamente. Sin embargo, ambos sirven propósitos ligeramente diferentes en la gestión de prioridades.

Integración de Suspense con Bibliotecas de Carga de Datos 🔄

Para usar Suspense de forma efectiva con la carga de datos, necesitas una biblioteca que sea compatible con el "protocolo" de Suspense. Estas bibliotecas están diseñadas para lanzar promesas cuando los datos no están listos y se integran perfectamente con los límites de Suspense.

Algunas opciones populares incluyen:

  • React Query (TanStack Query): Una biblioteca de gestión de estado de servidor que soporta Suspense para la carga inicial y las mutaciones.
  • SWR: Otra biblioteca de fetching de datos que también ofrece soporte para Suspense.
  • Relay: Un framework de GraphQL que ha sido pionero en la integración con Suspense para la carga de datos declarativa.
  • Fetch en Componentes de Servidor (RSC): En un futuro, directamente en React Server Components, el fetch nativo podría integrarse con Suspense de manera muy potente.

Ejemplo con React Query y Suspense

Primero, asegúrate de tener React Query instalado:

npm install @tanstack/react-query

Luego, configura tu QueryClientProvider y usa el suspense: true para habilitar el modo Suspense:

// App.js
import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// Crea un cliente de Query
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true, // ¡Habilita Suspense para todas las queries!
    },
  },
});

function MyDataComponent() {
  // Aquí usarías useQuery de @tanstack/react-query
  // Si los datos no están listos, este componente 'suspenderá'
  // y el Suspense boundary mostrará el fallback.
  // ... (ver ejemplo detallado a continuación)
  return <div>Datos cargados!</div>;
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <h1>Aplicación con React Query y Suspense</h1>
      <Suspense fallback={<div>Cargando datos principales...</div>}>
        <MyDataComponent />
      </Suspense>
    </QueryClientProvider>
  );
}

export default App;

Ahora, un componente que usa useQuery de React Query:

// components/PostList.js
import React from 'react';
import { useQuery } from '@tanstack/react-query';

async function fetchPosts() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  if (!response.ok) {
    throw new Error('Error al cargar posts');
  }
  return response.json();
}

function PostList() {
  // useQuery ahora suspenderá si los datos no están listos
  // gracias a la configuración suspense: true en QueryClientProvider
  const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });

  return (
    <div>
      <h2>Publicaciones</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>**{post.title}**</li>
        ))}
      </ul>
    </div>
  );
}

export default PostList;

En este escenario, PostList no necesita isLoading o error checks. Simplemente asume que posts estará disponible cuando se renderice. Si los datos no están listos, el Suspense más cercano en App.js mostrará su fallback.

💡 Consejo: Usa múltiples límites de Suspense para mostrar estados de carga granulares. Por ejemplo, un *fallback* global para toda la página, y *fallbacks* más pequeños dentro de secciones específicas que cargan datos.
App con QueryClientProvider Suspense Global (fallback: Spinner) MyDataComponent Suspense para Posts (fallback: Esqueleto) PostList Suspende Suspende

Estrategias y Mejores Prácticas 📖

Implementar el renderizado concurrente y Suspense de forma efectiva requiere un cambio de mentalidad. Aquí hay algunas estrategias y mejores prácticas:

1. Ubicación de los Límites de Suspense (Suspense Boundaries)

  • Granularidad: Coloca los límites de Suspense tan cerca como sea posible de los componentes que suspenden. Esto permite que solo una pequeña parte de la UI muestre un fallback, manteniendo el resto de la aplicación interactiva.
  • Experiencia de Usuario: Piensa en la experiencia del usuario. ¿Qué es más útil: un spinner que reemplaza toda una sección, o pequeños placeholders que muestran dónde aparecerá el contenido?

2. Manejo de Errores con ErrorBoundary

Suspense y el renderizado concurrente pueden interactuar con los errores de forma diferente. Si un componente lanza un error (no una promesa) durante el renderizado, este subirá por el árbol de componentes hasta que sea capturado por un Error Boundary.

Es fundamental envolver tus límites de Suspense (y cualquier componente que pueda suspender o fallar) con un ErrorBoundary para proporcionar una UI de error robusta. Un ErrorBoundary es un componente de clase que implementa componentDidCatch o static getDerivedStateFromError.

// components/ErrorBoundary.js
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // Actualiza el estado para que el próximo renderizado muestre la UI de fallback
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // También puedes registrar el error en un servicio de informes de errores
    console.error("Error capturado por ErrorBoundary:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Puedes renderizar cualquier UI de fallback personalizada
      return (
        <div style={{ padding: '20px', border: '1px solid red', color: 'red' }}>
          <h3>¡Algo salió mal!</h3>
          <p>{this.state.error && this.state.error.toString()}</p>
          <p>Por favor, inténtalo de nuevo más tarde.</p>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;
// App.js (usando ErrorBoundary)
import React, { Suspense } from 'react';
import ErrorBoundary from './components/ErrorBoundary';
import PostList from './components/PostList'; // Asume que PostList puede suspender

function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>Cargando aplicación...</div>}>
        <h1>Mi Aplicación Suspense</h1>
        <ErrorBoundary>
          {/* Suspense anidado dentro de otro ErrorBoundary para manejo específico */}
          <Suspense fallback={<div>Cargando lista de posts...</div>}>
            <PostList />
          </Suspense>
        </ErrorBoundary>
      </Suspense>
    </ErrorBoundary>
  );
}

export default App;

3. Evitar Cascadas de Carga (Loading Waterfalls)

Uno de los mayores beneficios de Suspense es la capacidad de evitar cascadas de carga donde un componente espera sus datos, y luego el componente renderizado espera sus propios datos, y así sucesivamente. Con Suspense, puedes iniciar la carga de datos para múltiples componentes en paralelo, o incluso iniciar la carga de datos antes de que se rendericen los componentes.

  • Data Fetching en Rutas: Con librerías como React Router v6.4+ (usando loaders) o herramientas como Next.js/Remix, puedes precargar los datos necesarios para una ruta antes de que se renderice el componente de la página, eliminando el estado de carga inicial en muchos casos.
  • Pre-fetching: Algunas librerías permiten precargar datos en segundo plano basándose en la intención del usuario (e.g., hover sobre un enlace).

4. Coherencia de la UI durante Transiciones

Usa useTransition para asegurarte de que la UI permanece interactiva y visualmente coherente durante actualizaciones de baja prioridad. Ofrecer un indicador visual (isPending) es crucial para que el usuario sepa que algo está sucediendo en segundo plano.

5. SuspenseList (Experimental)

Para controlar la coordinación de múltiples límites de Suspense que se revelan en orden, React ofrece un componente experimental SuspenseList. Esto es útil cuando tienes varios elementos que se cargan de forma asíncrona y quieres evitar que aparezcan de forma desordenada o abrupta.

// Este es un ejemplo conceptual de SuspenseList, actualmente experimental.
/*
<SuspenseList revealOrder="forwards" tail="collapsed">
  <Suspense fallback={<span>Cargando perfil...</span>}>
    <ProfileDetails />
  </Suspense>
  <Suspense fallback={<span>Cargando comentarios...</span>}>
    <Comments />
  </Suspense>
  <Suspense fallback={<span>Cargando sugerencias...</span>}>
    <Suggestions />
  </Suspense>
</SuspenseList>
*/

El revealOrder prop (por ejemplo, forwards o backwards) dicta cómo se revelan los fallbacks y el contenido. El tail prop controla si los fallbacks que aún no han sido revelados se muestran o se colapsan.


Casos de Uso Avanzados y Consideraciones 💡

El renderizado concurrente y Suspense son herramientas poderosas, pero su implementación óptima depende del contexto de tu aplicación.

Streaming HTML con Suspense (SSR/SSG)

Una de las aplicaciones más emocionantes del renderizado concurrente y Suspense es la capacidad de realizar Streaming HTML durante el Server-Side Rendering (SSR). En lugar de esperar a que todos los datos de la página se carguen en el servidor para enviar el HTML completo, React puede enviar el HTML de las partes de la página que están listas, y luego, a medida que los componentes internos resuelven sus datos, transmitir el HTML adicional para esos componentes.

Esto mejora significativamente el Time To First Byte (TTFB) y el First Contentful Paint (FCP), ya que el usuario ve algo en la pantalla mucho antes.

SSR Tradicional SSR con Suspense TIEMPO Servidor: Espera Datos (A, B, C) Envía HTML Completo (A, B, C) Servidor: Envía HTML Parcial (A) Streaming HTML: Envía B Streaming HTML: Envía C Iterativo y Progresivo Bloqueante (Todo o Nada)

Optimización de la Experiencia del Usuario (UX)

  • Feedback Instantáneo: Usa useTransition para mantener los campos de entrada y los controles interactivos, incluso cuando una acción costosa está ocurriendo en segundo plano.
  • Esqueletos de Contenido: En lugar de spinners genéricos, utiliza skeletons (esqueletos de contenido) como fallbacks de Suspense. Esto le da al usuario una idea de la estructura del contenido que se está cargando y reduce la "sorpresa" visual cuando el contenido real aparece.
  • Pre-cargando Datos: Si sabes que un usuario probablemente navegará a cierta ruta o hará clic en un elemento, puedes iniciar la carga de datos de forma proactiva utilizando las APIs de tu librería de fetching de datos compatible con Suspense.

Consideraciones de Depuración

Depurar aplicaciones con renderizado concurrente y Suspense puede ser un poco diferente:

  • Mensajes de Consola: Busca mensajes en la consola que indiquen que un componente está suspendiendo o que una transición está pendiente. Las React DevTools también proporcionan información sobre los componentes que están suspendiendo.
  • React DevTools: Las DevTools de React (a partir de la versión 4.25+) ofrecen funcionalidades mejoradas para inspeccionar el estado de Suspense y las transiciones.
  • Errores Lanzados: Recuerda que los errores de carga de datos que "lanzan" una promesa deben ser manejados por ErrorBoundarys en el nivel correcto.
⚠️ Advertencia: Evita envolver demasiada lógica en startTransition si esa lógica es crítica para la interacción inmediata del usuario. La clave es balancear la responsividad con la priorización.

Conclusión ✨

El renderizado concurrente y Suspense representan un cambio fundamental en cómo construimos y pensamos sobre las aplicaciones React. Al adoptar estas características, podemos crear interfaces de usuario mucho más fluidas, receptivas y con mejores experiencias de carga, reduciendo la complejidad del manejo manual de estados de carga.

Comienza a experimentar con useTransition y useDeferredValue para refinar la interacción de tu UI, y explora la integración de Suspense con tu biblioteca favorita de carga de datos. ¡El futuro de React es concurrente, y ahora estás un paso más cerca de dominarlo! 🚀

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!