Inicio / Artículos / Recuperación paralela en React: Suspense con Promise.all y allSettled

Recuperación paralela en React: Suspense con Promise.all y allSettled

Aprenda cómo eliminar las cascadas de solicitudes con Promise.all, evitar que los datos opcionales dañen las páginas con Promise.allSettled y mostrar estados de carga con Suspense.

1327 palabras

Una página que muestra spinner tras spinner suele tener uno de dos problemas: o bien sus solicitudes se ejecutan una tras otra cuando podrían hacerlo simultáneamente, o bien alguna solicitud opcional, como un widget de recomendaciones, falla y arruina toda la vista. Tres herramientas abordan ambos problemas: Promise.all y Promise.allSettled para iniciar tareas de forma concurrente con diferentes políticas de error, y Suspense de React para mostrar un contenido alternativo mientras los datos están en proceso. Esta guía explica cada una de ellas con una pequeña demostración que carga dos listas desde una API pública.

De dónde provienen las cascadas

Ejecutar las llamadas una tras otra de forma secuencial las serializa. La segunda solicitud ni siquiera comienza hasta que la primera ha finalizado, por lo que la latencia total es la suma de ambas:

const categories = await fetchCategories();
// fetchPosts will only start running after fetchCategories finishes executing.
const posts = await fetchPosts();

Cuando las solicitudes no dependen unas de otras, inicia ambas primero y espera por ellas juntas. El tiempo total entonces disminuye a aproximadamente el del pedido más lento, en lugar de ser la suma. Para una comparación más amplia de estos combinadores, consulta elegir entre Promise.all, Promise.race y await secuencial.

Suspense: estados de carga declarativos

Suspense envuelve parte del árbol y muestra un fallback, como un esqueleto o un mensaje de “Cargando datos...”, mientras todo lo que está dentro espera. El resto de la página permanece visible, y ya no es necesario utilizar una bandera loading escrita a mano para esa sección.

En la demostración, una función asíncrona espera artificialmente tres segundos para que se vea el estado de carga, luego lanza las solicitudes relacionadas con los hechizos y los libros de forma concurrente y las combina con Promise.all. La promesa resultante se pasa a Section1, que la desempaca mediante el gancho use de React. Hasta que la promesa se resuelve, use suspende el componente y se muestra la opción de respaldo.

'use client';

import React, { Suspense, use } from 'react';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function Home() {
  const fetchDataParalel = async () => {
    await delay(3000); // Simulate a 3-second loading delay

    // Trigger both APIs in PARALLEL (simultaneously)
    const getSpellsPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
    ).then((res) => res.json());
    const getBooksPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
    ).then((res) => res.json());

    // Wait for both to finish together
    const [spellsData, booksData] = await Promise.all([
      getSpellsPromise,
      getBooksPromise,
    ]);

    return {
      spells: spellsData?.map((item: any) => item.spell) || [],
      books: booksData?.map((item: any) => item.title) || [],
    };
  };

  return (
    <main className="p-4">
      <p>
        <strong>Demo Suspense + Promise.all</strong>
      </p>
      <br />

      <Suspense fallback={<p>Loading data...</p>}>
        <Section1 dataPromise={fetchDataParalel()} />
      </Suspense>
    </main>
  );
}

function Section1({
  dataPromise,
}: {
  dataPromise: Promise<{ spells: string[]; books: string[] }>;
}) {
  const data = use(dataPromise);

  return (
    <div>
      <p>
        <strong>Spells: </strong>{' '}
        {data.spells.length ? data.spells.join(', ') : '-'}
      </p>
      <p>
        <strong>Books: </strong>{' '}
        {data.books.length ? data.books.join(', ') : '-'}
      </p>
    </div>
  );
}

Una advertencia: este componente llama a fetchDataParalel() durante el renderizado, por lo que se crea una nueva promesa cada vez que se renderiza Home. React espera que las promesas pasadas a use sean estables, y en un componente cliente una promesa no cachée puede generar advertencias o solicitudes repetidas. En Next.js, la versión más idiomática crea la promesa en un componente servidor (o la cachea) y la pasa hacia abajo, o simplemente convierte la sección en un componente servidor asíncrono envuelto en Suspense.

Promise.all: todo o nada

Promise.all es adecuado para datos que son todos obligatorios. Se resuelve con un array de resultados en el orden original una vez que cada entrada se cumple, y rechaza inmediatamente cuando alguna entrada falla. La segunda demostración utiliza la misma solicitud paralela, esta vez desde useEffect con un estado loading explícito en lugar de Suspense:

'use client';

import React, { Suspense, use } from 'react';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function HomePromiseAll() {
  const [loading, setLoading] = React.useState(false);
  const [spells, setSpells] = React.useState([]);
  const [books, setBooks] = React.useState([]);

  const fetchDataParalel = async () => {
    setLoading(true);
    await delay(3000);

    const getSpellsPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
    ).then((res) => res.json());
    const getBooksPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
    ).then((res) => res.json());

    const [spellsData, booksData] = await Promise.all([
      getSpellsPromise,
      getBooksPromise,
    ]);
    setSpells(spellsData?.map((item: any) => item.spell) || []);
    setBooks(booksData?.map((item: any) => item.title) || []);
    setLoading(false);
  };

  React.useEffect(() => {
    fetchDataParalel();
  }, []);

  return (
    <main>
      <h1>Demo Promise.all</h1>
      <br />
      {loading ? (
        <p>Loading data...</p>
      ) : (
        <Section1 spells={spells} books={books} />
      )}
    </main>
  );
}

function Section1({ spells, books }: { spells: string[]; books: string[] }) {
  return (
    <div>
      <p>
        <strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
      </p>
      <p>
        <strong>Books: </strong> {books.length ? books.join(', ') : '-'}
      </p>
    </div>
  );
}

Observe que si alguna solicitud falla, la ejecución nunca llega a setLoading(false), y la página muestra “Cargando datos...” para siempre. Envuelva el cuerpo en try/finally, y muestre un estado de error en catch, para que la falla quede visible en lugar de ser ignorada. Promise.all tampoco cancela la otra solicitud cuando una falla; simplemente deja de esperarla.

Promise.allSettled: tolera fallos parciales

Promise.allSettled nunca rechaza. Espera a que se procesen todos los elementos de entrada y devuelve un objeto por cada uno, con un status que puede ser fulfilled (con un value) o rejected (con un reason). Luego usted decide qué mostrar según cada resultado:

'use client';

import React from 'react';

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function HomePromiseAllSettled() {
  const [loading, setLoading] = React.useState(false);
  const [spells, setSpells] = React.useState<string[]>([]);
  const [books, setBooks] = React.useState<string[]>([]);

  const fetchDataParalel = async () => {
    setLoading(true);
    await delay(3000);

    const getSpellsPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
    ).then((res) => res.json());

    const getBooksPromise = fetch(
      'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
    ).then((res) => res.json());

    const [spellsResult, booksResult] = await Promise.allSettled([
      getSpellsPromise,
      getBooksPromise,
    ]);

    // check status 'fulfilled' and get from `.value`
    const finalSpells =
      spellsResult.status === 'fulfilled'
        ? spellsResult.value.map((item: any) => item.spell)
        : [];

    const finalBooks =
      booksResult.status === 'fulfilled'
        ? booksResult.value.map((item: any) => item.title)
        : [];

    setSpells(finalSpells);
    setBooks(finalBooks);
    setLoading(false);
  };

  React.useEffect(() => {
    fetchDataParalel();
  }, []);

  return (
    <main>
      <h1>Demo Promise.allSettled</h1>
      <br />
      {loading ? (
        <p>Loading data...</p>
      ) : (
        <Section1 spells={spells} books={books} />
      )}
    </main>
  );
}

function Section1({ spells, books }: { spells: string[]; books: string[] }) {
  return (
    <div>
      <p>
        <strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
      </p>
      <p>
        <strong>Books: </strong> {books.length ? books.join(', ') : '-'}
      </p>
    </div>
  );
}

Aquí, una solicitud fallida genera una lista vacía mientras los libros siguen mostrándose. Esta es la política adecuada para el contenido secundario, pero tenga en cuenta qué se considera un fallo. fetch solo rechaza en caso de errores de red, y la llamada a .json() solo rechaza cuando el cuerpo no puede ser analizado. Un error HTTP 500 que devuelva JSON sigue considerándose fulfilled; por lo tanto, verifique res.ok dentro de cada cadena si desea que los errores del servidor se traten como rechazos.

Elegir la combinación adecuada

  • Use Promise.all cuando la vista no tiene sentido sin cada uno de los datos, y úsela junto con un límite de errores o un estado de error explícito.
  • Use Promise.allSettled cuando algunas secciones son opcionales y una página parcial es mejor que ninguna.
  • Rodee cada sección que se carga de forma independiente con su propio límite Suspense para que los datos lentos afecten únicamente a esa región.
  • Mantenga las promesas estables: créelas en el servidor o cámbielas en caché, no en cada renderizado del cliente.

Conclusión

Los modelos en cascada y los puntos únicos de fallo son decisiones arquitectónicas y no inevitabilidades. Comenzar con tareas independientes al mismo tiempo, elegir deliberadamente una política de manejo de fallos y dejar que Suspense gestione las regiones en carga permite ofrecer a los usuarios una página que se muestra rápidamente y se degrada de manera adecuada cuando alguna dependencia falla.

Lecturas relacionadas