Pagination côté client en vanilla JS : simple, légère, zéro framework

Le blog charge ses articles depuis un posts.json via fetch() et les rend en vanilla JS. Pas de framework, pas de build. Avec 7 articles ça marche parfaitement. À 50 articles, on veut une pagination — pour la lisibilité, pas les performances (le JSON reste léger de toute façon). La contrainte principale : pas d'éléments cachés dans le DOM.

La mauvaise façon de faire : tout charger puis display: none ce qu'on ne montre pas. La bonne façon : ne rendre que les posts de la page courante. La différence paraît cosmétique sur un blog personnel. Elle reflète une habitude de travail — ne pas mettre dans le DOM ce qu'on n'affiche pas.

L'architecture existante

Le setup de départ : fetch('posts.json') charge le manifeste, les articles sont triés par date, puis render(allPosts) écrit dans #posts-container via innerHTML. La fonction filterPosts() filtre le tableau complet par catégorie et recherche texte. applyFilters() est appelée à chaque interaction — clic sur un filtre, frappe dans le champ de recherche.

Le point d'entrée de la pagination est la fonction render() — c'est elle qui décide quoi écrire dans le DOM. Modifier filterPosts() n'est pas nécessaire : elle continue à retourner le tableau complet filtré, sans pagination. C'est render() qui en extrait la tranche.

Le mauvais pattern à éviter

// ❌ Tout charger, tout cacher — inutile et trompeur
function render(posts) {
    container.innerHTML = posts.map(function(post, index) {
        return '<article style="display:' + (index < 10 ? 'block' : 'none') + '">'
            + buildCardHTML(post)
            + '</article>';
    }).join('');
}

Le DOM contient 50 éléments, l'utilisateur en voit 10. Le CSS de la page fait du travail inutile, les screen readers lisent le contenu caché, et le "chargement léger" est une illusion. Ce pattern est courant parce qu'il est rapide à écrire. Il est aussi faux dans ses fondamentaux.

L'implémentation

Deux variables d'état ajoutées au niveau module :

var currentPage = 1;
var PAGE_SIZE = 10;

La fonction render() modifiée — elle reçoit le tableau filtré complet, en extrait la tranche de la page courante, et rend uniquement ces posts :

function render(filteredPosts) {
    var container = document.getElementById('posts-container');
    var noResults = document.getElementById('no-results');
    var pagination = document.getElementById('blog-pagination');

    if (filteredPosts.length === 0) {
        container.innerHTML = '';
        noResults.style.display = '';
        pagination.innerHTML = '';
        return;
    }

    noResults.style.display = 'none';

    var totalPages = Math.ceil(filteredPosts.length / PAGE_SIZE);
    if (currentPage > totalPages) currentPage = totalPages;

    var start = (currentPage - 1) * PAGE_SIZE;
    container.innerHTML = filteredPosts
        .slice(start, start + PAGE_SIZE)
        .map(buildCardHTML)
        .join('');

    if (totalPages <= 1) { pagination.innerHTML = ''; return; }

    var prevDisabled = currentPage === 1 ? ' disabled' : '';
    var nextDisabled = currentPage === totalPages ? ' disabled' : '';
    pagination.innerHTML =
        '<button class="btn-page"' + prevDisabled + ' data-page="' + (currentPage - 1) + '">&larr; Précédent</button>'
        + '<span class="page-info">Page ' + currentPage + ' sur ' + totalPages + '</span>'
        + '<button class="btn-page"' + nextDisabled + ' data-page="' + (currentPage + 1) + '">Suivant &rarr;</button>';
}

Le if (currentPage > totalPages) currentPage = totalPages en milieu de fonction n'est pas défensif pour rien : si l'utilisateur est sur la page 5 et fait une recherche qui ne retourne que 3 articles (donc 1 page), il faut ramener currentPage à 1 avant de calculer le start. Sinon slice(40, 50) sur un tableau de 3 éléments retourne un tableau vide — et le container s'affiche vide alors qu'il y a des résultats.

Reset de page sur filtre

Quand l'utilisateur change de catégorie ou fait une recherche, on revient à la page 1. C'est le seul endroit où currentPage est réinitialisé :

function applyFilters() {
    currentPage = 1;  // ← toujours page 1 sur nouveau filtre
    render(filterPosts());
}

Le handler de pagination, lui, ne remet pas à 1 — il met juste à jour currentPage et re-render :

document.getElementById('blog-pagination').addEventListener('click', function (e) {
    var btn = e.target.closest('[data-page]');
    if (!btn || btn.disabled) return;
    currentPage = parseInt(btn.getAttribute('data-page'), 10);
    render(filterPosts());
    window.scrollTo({ top: 0, behavior: 'smooth' });
});

Délégation d'événement sur le container #blog-pagination — pas de listeners attachés aux boutons individuels. Les boutons sont recréés à chaque render via innerHTML, donc des listeners attachés directement seraient perdus à chaque mise à jour. La délégation évite ça proprement : un seul listener sur un élément stable, qui survit aux re-renders.

Ce qu'on n'a pas fait

Quelques choix délibérés de ne PAS sur-ingénier :

  • Pas de numérotation de pages (1, 2, 3, …, N) — avec 10 articles par page, le "Page X sur Y" suffit jusqu'à plusieurs centaines d'articles. La numérotation complète ajoute de la complexité pour tronquer les séries longues (les fameux "1 2 … 5 6 7 … 12 13"). Ce n'est pas justifié ici.
  • Pas d'URL avec ?page=2 — le blog est un portfolio, pas un moteur de recherche. L'état de pagination n'a pas besoin d'être partageable ou indexable par Google. Ajouter history.pushState() représente 3x plus de code pour un bénéfice nul dans ce contexte.
  • Pas de debounce sur la rechercheposts.json est un fichier local déjà chargé en mémoire, le filtrage est synchrone et instantané. Le debounce est une optimisation pour les requêtes réseau, pas pour un Array.filter() sur 50 éléments.
  • Pas de transition CSS entre pages — une pagination de contenu blog n'est pas un carousel. Le remplacement direct est plus lisible et plus prévisible. Une animation de fade-in retarderait la lecture sans apporter d'information.

Le CSS

Cohérent avec le reste du blog (Montserrat, vert #3aaa64, bordures grises). Les boutons désactivés ont opacity: 0.5 et cursor: default — pas de JS pour ça, juste l'attribut HTML disabled qui déclenche les pseudo-classes CSS :disabled. C'est l'usage prévu par la plateforme ; pas besoin de réimplémenter manuellement ce que le navigateur fait déjà.

Conclusion

30 lignes de JS. Zéro dépendance. DOM léger à chaque page. La pagination s'intègre proprement dans le flux existant : filterPosts() retourne toujours le tableau complet filtré, render() en extrait la tranche — les deux fonctions restent découplées. Ajouter la pagination n'a pas nécessité de restructurer le code existant : juste deux variables d'état et une modification de render().

C'est le bon niveau d'ingénierie pour un blog personnel.

Commentaires (0)