Fetch API e paginazione a cursore: caricare dati senza duplicati

by Anastasia P.
SHARE
Fetch API e paginazione a cursore: caricare dati senza duplicati
© Guida-HTML5.it

Introduzione

Quando un’applicazione deve visualizzare molti elementi, scaricare l’intero dataset in una sola chiamata HTTP è spesso una scelta poco efficiente. Un elenco di prodotti, messaggi o articoli può contenere migliaia di record: trasferirli tutti insieme aumenta il tempo di caricamento e il consumo di memoria.

La soluzione consiste nella paginazione, cioè nella suddivisione dei risultati in più pagine. In questo tutorial analizzeremo un caso pratico: usare la Fetch API per caricare progressivamente una lista di articoli tramite un cursor. Il cursore è un valore restituito dal server che indica da quale posizione iniziare la richiesta successiva.

A differenza della paginazione basata soltanto su numeri di pagina, la paginazione a cursore è più affidabile quando i dati cambiano frequentemente. Per esempio, se nuovi articoli vengono inseriti mentre l’utente scorre la lista, il cursore riduce il rischio di saltare elementi o visualizzare duplicati.

Codice completo

L’esempio seguente crea un pulsante “Carica altri”, aggiorna l’interfaccia e impedisce l’inserimento di articoli duplicati. L’API ipotizzata restituisce una struttura simile a questa:

{
  "items": [
    {
      "id": 101,
      "title": "Introduzione a JavaScript",
      "summary": "Concetti fondamentali del linguaggio"
    }
  ],
  "nextCursor": "eyJwYWdlIjoyfQ=="
}

HTML minimo dell’interfaccia:

<section>
  <h2>Articoli recenti</h2>

  <ul id="article-list"></ul>

  <p id="status" role="status"></p>

  <button id="load-more" type="button">
    Carica altri
  </button>
</section>

Codice JavaScript completo:

const listElement = document.querySelector("#article-list");
const statusElement = document.querySelector("#status");
const loadMoreButton = document.querySelector("#load-more");

// Il cursore viene fornito dal server dopo ogni risposta.
let nextCursor = null;

// Manteniamo gli ID già visualizzati per evitare duplicati.
const loadedIds = new Set();

// Evita richieste simultanee causate da doppi clic.
let isLoading = false;

function createArticleElement(article) {
  const item = document.createElement("li");

  const title = document.createElement("h3");
  title.textContent = article.title;

  const summary = document.createElement("p");
  summary.textContent = article.summary;

  item.append(title, summary);

  return item;
}

async function loadArticles() {
  if (isLoading) {
    return;
  }

  isLoading = true;
  loadMoreButton.disabled = true;
  statusElement.textContent = "Caricamento in corso...";

  try {
    const url = new URL("/api/articles", window.location.origin);

    // Il parametro viene aggiunto solo dopo la prima richiesta.
    if (nextCursor !== null) {
      url.searchParams.set("cursor", nextCursor);
    }

    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`Errore HTTP: ${response.status}`);
    }

    const data = await response.json();

    if (!Array.isArray(data.items)) {
      throw new Error("Formato della risposta non valido");
    }

    const fragment = document.createDocumentFragment();

    for (const article of data.items) {
      // Ignora elementi già caricati.
      if (loadedIds.has(article.id)) {
        continue;
      }

      loadedIds.add(article.id);
      fragment.appendChild(createArticleElement(article));
    }

    listElement.appendChild(fragment);

    // Il server può restituire null quando non ci sono altre pagine.
    nextCursor = data.nextCursor ?? null;

    if (nextCursor === null) {
      loadMoreButton.hidden = true;
      statusElement.textContent = "Hai raggiunto la fine dell’elenco.";
    } else {
      loadMoreButton.disabled = false;
      statusElement.textContent = "";
    }
  } catch (error) {
    console.error("Impossibile caricare gli articoli:", error);
    statusElement.textContent =
      "Non è stato possibile caricare gli articoli.";

    // Permette di riprovare dopo un errore temporaneo.
    loadMoreButton.disabled = false;
  } finally {
    isLoading = false;
  }
}

loadMoreButton.addEventListener("click", loadArticles);

// Carica automaticamente la prima pagina.
loadArticles();

Spiegazione

Il ruolo del cursore

All’avvio, nextCursor vale null, quindi la prima richiesta viene inviata senza parametro. Il server restituisce gli articoli iniziali e, se esistono altri risultati, un nuovo valore nextCursor.

Alla richiesta successiva il cursore viene aggiunto all’URL tramite URLSearchParams. Non è necessario costruire manualmente stringhe come ?cursor=...: l’API browser gestisce correttamente l’encoding dei caratteri speciali.

Prevenire i duplicati

La struttura Set contiene gli identificativi degli articoli già mostrati. Prima di aggiungere un elemento al DOM, il programma controlla se il suo ID è già presente. Questo controllo è utile quando il backend può restituire dati sovrapposti o quando l’utente ripete una richiesta.

Perché usare DocumentFragment

Gli elementi vengono prima aggiunti a un DocumentFragment e solo dopo inseriti nella lista. In questo modo il browser modifica il DOM una sola volta per ogni pagina, invece di eseguire un aggiornamento dopo ogni articolo. Il vantaggio è più evidente quando una risposta contiene molti elementi.

Gestire lo stato della richiesta

La variabile isLoading impedisce che due richieste partano contemporaneamente. Inoltre, il pulsante viene disabilitato durante il caricamento. Questa protezione evita risultati fuori ordine e chiamate duplicate causate da clic rapidi o ripetuti.

Riconoscere l’ultima pagina

Quando il server imposta nextCursor a null, non ci sono altre pagine disponibili. Il pulsante viene quindi nascosto e l’utente riceve un messaggio informativo. In altri sistemi il backend potrebbe usare un campo booleano come hasMore; il principio rimane identico.

Best practice

  • Definisci un contratto API chiaro: stabilisci il nome dei campi, il tipo del cursore e il comportamento quando non esistono altri risultati.
  • Non fidarti ciecamente della risposta: verifica almeno che la collezione ricevuta sia un array e che ogni elemento abbia un identificativo.
  • Usa identificativi stabili: il controllo dei duplicati funziona correttamente solo se ogni record possiede un ID univoco.
  • Non concatenare HTML ricevuto dal server: crea nodi con textContent per ridurre il rischio di inserire markup non sicuro.
  • Prevedi il retry: dopo un errore di rete lascia l’interfaccia in uno stato che permetta all’utente di riprovare.
  • Considera il caricamento automatico: per un’esperienza di infinite scroll puoi usare IntersectionObserver su un elemento sentinella posto sotto la lista.
  • Limita la dimensione delle pagine: una risposta troppo grande annulla i vantaggi della paginazione.
  • Gestisci il cambiamento dei filtri: quando l’utente applica un nuovo filtro, svuota la lista, azzera il Set e reimposta il cursore a null.

Riepilogo

La Fetch API può gestire efficacemente liste di grandi dimensioni se viene combinata con una strategia di paginazione. La paginazione a cursore consente di richiedere solo i dati necessari e si adatta meglio ai dataset che cambiano nel tempo.

Il flusso essenziale è composto da questi passaggi:

  • effettuare la prima richiesta senza cursore;
  • salvare il cursore restituito dal server;
  • usarlo nella richiesta successiva;
  • evitare duplicati tramite una struttura Set;
  • disabilitare i controlli durante il caricamento;
  • nascondere il pulsante quando non esistono altre pagine.

Approfondisci con risorse ufficiali

SHARE