Développement Web

Comment créer un système de filtrage en JavaScript

11 min de lecture

Prérequis

  • Niveau : bases JavaScript (méthodes Array filter, sort, map) et manipulation du DOM.
  • Outils : VS Code + Live Server, navigateur moderne.
  • Temps estimé : 1 h 30.

Pourquoi un filtrage côté client ?

Pour des listes de moins de 1 000 éléments, le filtrage côté client est instantané et sans appel serveur. Au-delà, on bascule sur un filtrage côté serveur (avec pagination + index DB). Ce tutoriel montre les patterns clients qui transposent ensuite directement en React/Vue/Svelte.

Le filtrage en temps réel : indispensable pour toute liste

Un système de filtrage permet aux utilisateurs de trouver rapidement ce qu’ils cherchent dans une liste de produits, articles, contacts ou tout autre type de données. Dans ce tutoriel, vous construisez un système complet avec recherche textuelle, filtres par catégorie, tri, et mise à jour en temps réel — le tout en JavaScript pur.

Les données

const produits = [
  { id: 1, nom: 'MacBook Pro', categorie: 'laptop', prix: 850000, stock: true },
  { id: 2, nom: 'iPhone 15', categorie: 'téléphone', prix: 650000, stock: true },
  { id: 3, nom: 'AirPods Pro', categorie: 'accessoire', prix: 150000, stock: false },
  { id: 4, nom: 'Galaxy S24', categorie: 'téléphone', prix: 550000, stock: true },
  { id: 5, nom: 'Dell XPS 15', categorie: 'laptop', prix: 700000, stock: true },
  { id: 6, nom: 'Clavier Logitech', categorie: 'accessoire', prix: 35000, stock: true },
  { id: 7, nom: 'ThinkPad X1', categorie: 'laptop', prix: 900000, stock: false },
  { id: 8, nom: 'Souris MX Master', categorie: 'accessoire', prix: 45000, stock: true },
  { id: 9, nom: 'iPad Pro', categorie: 'tablette', prix: 600000, stock: true },
  { id: 10, nom: 'Pixel 8', categorie: 'téléphone', prix: 450000, stock: true }
];

Le HTML

<div class="filter-app">
  <!-- Barre de recherche -->
  <input type="text" id="searchInput" placeholder="Rechercher un produit...">
  
  <!-- Filtres par catégorie -->
  <div class="filter-buttons" id="categoryFilters">
    <button class="filter-btn actif" data-category="all">Tous</button>
    <button class="filter-btn" data-category="laptop">Laptops</button>
    <button class="filter-btn" data-category="telephone">Téléphones</button>
    <button class="filter-btn" data-category="tablette">Tablettes</button>
    <button class="filter-btn" data-category="accessoire">Accessoires</button>
  </div>
  
  <!-- Options supplémentaires -->
  <div class="filter-options">
    <label><input type="checkbox" id="stockOnly"> En stock uniquement</label>
    <select id="sortSelect">
      <option value="nom">Trier par nom</option>
      <option value="prix-asc">Prix croissant</option>
      <option value="prix-desc">Prix décroissant</option>
    </select>
  </div>
  
  <!-- Résultats -->
  <p id="resultCount"></p>
  <div id="productGrid" class="product-grid"></div>
</div>

Le JavaScript : moteur de filtrage

// État des filtres
let filters = {
  search: '',
  category: 'all',
  stockOnly: false,
  sort: 'nom'
};

// Éléments DOM
const searchInput = document.getElementById('searchInput');
const categoryBtns = document.querySelectorAll('.filter-btn');
const stockCheckbox = document.getElementById('stockOnly');
const sortSelect = document.getElementById('sortSelect');
const grid = document.getElementById('productGrid');
const countEl = document.getElementById('resultCount');

// Appliquer tous les filtres
function getFilteredProducts() {
  let result = [...produits];
  
  // 1. Filtre texte
  if (filters.search) {
    const query = filters.search.toLowerCase();
    result = result.filter(p => 
      p.nom.toLowerCase().includes(query)
    );
  }
  
  // 2. Filtre catégorie
  if (filters.category !== 'all') {
    result = result.filter(p => p.categorie === filters.category);
  }
  
  // 3. Filtre stock
  if (filters.stockOnly) {
    result = result.filter(p => p.stock);
  }
  
  // 4. Tri
  result.sort((a, b) => {
    switch (filters.sort) {
      case 'prix-asc': return a.prix - b.prix;
      case 'prix-desc': return b.prix - a.prix;
      default: return a.nom.localeCompare(b.nom);
    }
  });
  
  return result;
}

// Afficher les produits
function render() {
  const filtered = getFilteredProducts();
  
  countEl.textContent = filtered.length + ' produit(s) trouvé(s)';
  
  if (filtered.length === 0) {
    grid.innerHTML = '<p class="no-results">Aucun produit ne correspond à vos critères.</p>';
    return;
  }
  
  grid.innerHTML = filtered.map(p => 
    '<div class="product-card ' + (!p.stock ? 'out-of-stock' : '') + '">' +
      '<h3>' + p.nom + '</h3>' +
      '<span class="category">' + p.categorie + '</span>' +
      '<p class="price">' + p.prix.toLocaleString('fr-FR') + ' FCFA</p>' +
      '<span class="stock ' + (p.stock ? 'in-stock' : '') + '">' +
        (p.stock ? 'En stock' : 'Rupture') +
      '</span>' +
    '</div>'
  ).join('');
}

// Écouteurs d'événements
searchInput.addEventListener('input', (e) => {
  filters.search = e.target.value;
  render();
});

categoryBtns.forEach(btn => {
  btn.addEventListener('click', () => {
    categoryBtns.forEach(b => b.classList.remove('actif'));
    btn.classList.add('actif');
    filters.category = btn.dataset.category;
    render();
  });
});

stockCheckbox.addEventListener('change', (e) => {
  filters.stockOnly = e.target.checked;
  render();
});

sortSelect.addEventListener('change', (e) => {
  filters.sort = e.target.value;
  render();
});

// Affichage initial
render();

Comment ça fonctionne

Le système suit un pattern simple :

  1. L’utilisateur interagit avec un contrôle (recherche, filtre, tri)
  2. L’état filters est mis à jour
  3. La fonction render() est appelée
  4. getFilteredProducts() applique TOUS les filtres dans l’ordre
  5. Le DOM est mis à jour avec les résultats

Tous les filtres sont cumulatifs : si vous cherchez « Mac » dans la catégorie « laptop » avec « en stock uniquement », les 3 filtres s’appliquent ensemble.

Erreurs fréquentes

Performance qui chute après 5 000 éléments

Cause : on filtre + reconstruit tout le DOM à chaque frappe.
Solution : ajoutez un debounce de 200 ms sur l’input de recherche, et envisagez la virtualisation (virtual scroller) au-delà de 10 000 lignes.

Recherche insensible aux accents qui ne marche pas

Cause : "téléphone".includes("telephone") → false.
Solution : normalisez les deux côtés : str.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().

Filtres qui se perdent au rechargement

Cause : état stocké uniquement en mémoire JS.
Solution : sérialisez les filtres dans l’URL avec URLSearchParams, puis lisez-les au chargement. Bonus : l’utilisateur peut partager le lien filtré.

Identifiants/clés d’objet accentués

Cause : propriétés catégorie et attributs data-category="téléphone" avec accent.
Solution : ASCII pour les clés et attributs (categorie, data-category="telephone"), gardez les libellés affichés en français.

Exercice

  1. Ajoutez un filtre de prix avec un double slider (min/max)
  2. Ajoutez un compteur de filtres actifs sur chaque catégorie
  3. Sauvegardez les filtres dans l’URL (query params) pour que l’utilisateur puisse partager une recherche filtrée
  4. Ajoutez une animation de transition quand les cartes apparaissent/disparaissent

Pour étoffer le tableau

Etape 1 : Definir la structure HTML de la liste a filtrer

Un systeme de filtrage utile a 90 % du commerce francophone (catalogue formations, annuaire prestataires Plateau, liste articles blog) repose sur trois zones : un panneau de filtres a gauche, une barre de recherche en haut, une grille de cartes a droite. Commencez par un HTML semantique propre, sans framework, qui marchera meme sur un Tecno Spark en 2G a Saint-Louis.

<aside id="filters">
  <input type="search" id="q" placeholder="Rechercher...">
  <fieldset>
    <legend>Categorie</legend>
    <label><input type="checkbox" value="dev" name="cat"> Developpement</label>
    <label><input type="checkbox" value="cyber" name="cat"> Cybersecurite</label>
    <label><input type="checkbox" value="data" name="cat"> Data</label>
  </fieldset>
  <select id="sort">
    <option value="default">Par defaut</option>
    <option value="price-asc">Prix croissant</option>
    <option value="price-desc">Prix decroissant</option>
  </select>
</aside>
<main id="results"></main>

Aucun framework requis. Le balisage est accessible (label englobant l input), indexable Google, et compatible lecteurs d ecran NVDA et VoiceOver.

Etape 2 : Charger les donnees en JSON

Stockez vos elements dans un fichier data.json a cote de la page. Pour 200 a 2 000 entrees, c est plus rapide qu un appel API et fonctionne offline en GitHub Pages ou Netlify.

const items = await fetch('/data.json').then(r => r.json());
// items = [{id:1,title:"Pentest Web",cat:"cyber",price:150000}, ...]
console.log(items.length, "items charges");

Vous devez voir dans la console le nombre exact d items. Si fetch echoue avec CORS, ouvrez la page via un serveur local (npx serve ou python3 -m http.server 8000) et non en double-clic file://.

Etape 3 : Ecrire la fonction de filtrage pure

Separez la logique de filtrage du rendu DOM. Une fonction pure prend l etat (recherche, categories, tri) et retourne le tableau filtre — facile a tester, facile a debug.

function filterItems(items, {q, cats, sort}) {
  let out = items.filter(it => {
    if (q && !it.title.toLowerCase().includes(q.toLowerCase())) return false;
    if (cats.length && !cats.includes(it.cat)) return false;
    return true;
  });
  if (sort === 'price-asc')  out.sort((a,b) => a.price - b.price);
  if (sort === 'price-desc') out.sort((a,b) => b.price - a.price);
  return out;
}

La fonction retourne un nouveau tableau, ne mute jamais items, et reste lisible. Vous pouvez la tester en console : filterItems(items,{q: »pen »,cats:[],sort: »default »}) doit renvoyer toutes les entrees contenant pen.

Etape 4 : Brancher les evenements input avec debounce

Ecouter input sur la barre de recherche declenche un re-render a chaque touche. Sur un mobile entree de gamme, cela bloque l UI. Encapsulez dans un debounce de 200 ms — assez pour fluidifier sans paraitre lent.

const debounce = (fn, ms=200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
const state = { q:'', cats:[], sort:'default' };
const render = () => {
  const list = filterItems(items, state);
  results.innerHTML = list.map(it => `<article><h3>${it.title}</h3><p>${it.price.toLocaleString('fr-FR')} FCFA</p></article>`).join('');
};
document.getElementById('q').addEventListener('input', debounce(e => { state.q = e.target.value; render(); }));
document.querySelectorAll('input[name=cat]').forEach(cb => cb.addEventListener('change', () => {
  state.cats = [...document.querySelectorAll('input[name=cat]:checked')].map(c => c.value);
  render();
}));
document.getElementById('sort').addEventListener('change', e => { state.sort = e.target.value; render(); });
render();

Sauvegardez, rechargez, tapez data dans la barre : seules les entrees Data restent visibles. Cochez cyber : la liste se reduit encore.

Etape 5 : Synchroniser avec l URL pour le partage

Un client a Bamako veut partager par WhatsApp un filtre Cybersecurite a moins de 200 000 FCFA. Encodez l etat dans l URL avec URLSearchParams : la page rechargee restitue le meme filtre.

function syncURL() {
  const p = new URLSearchParams();
  if (state.q) p.set('q', state.q);
  if (state.cats.length) p.set('cats', state.cats.join(','));
  if (state.sort !== 'default') p.set('sort', state.sort);
  history.replaceState(null, '', p.toString() ? '?' + p : location.pathname);
}
// Appelez syncURL() a la fin de render()
// Et au chargement, lisez l URL pour reconstituer state

Verifiez : modifiez un filtre, copiez l URL, ouvrez-la dans un autre onglet — vous retrouvez exactement la meme liste filtree.

Etape 6 : Afficher un compteur et un etat vide

Toujours dire combien de resultats correspondent et que faire quand zero match. Sans cela, l utilisateur croit que le site est casse.

// Dans render()
const list = filterItems(items, state);
if (list.length === 0) {
  results.innerHTML = '<p>Aucun resultat. Essayez de retirer un filtre ou de changer le mot-cle.</p>';
  return;
}
results.insertAdjacentHTML('afterbegin', `<p>${list.length} resultat(s)</p>`);

Le message vide propose une action concrete, ce qui evite le rebond.

Etape 7 : Optimiser pour 10 000 elements avec virtual scroll

Au-dela de 1 000 cartes, innerHTML devient lent. Utilisez une bibliotheque comme virtua (15 Ko gzippe) ou implementez un IntersectionObserver simple qui ne rend que les 50 premieres cartes et ajoute les suivantes au scroll. Sur la majorite des catalogues francophones, vous n atteindrez jamais ce seuil — restez simple tant que les performances tiennent.

Etape 8 : Tester sur Chrome, Safari et Firefox

Chrome 131, Safari 18 et Firefox 132 supportent toutes les API utilisees (fetch, URLSearchParams, sort stable). Verifiez quand meme : ouvrez DevTools onglet Lighthouse, lancez un audit Mobile — score Performance attendu > 95, Accessibilite > 90. Si Performance plonge, le coupable est presque toujours une image non optimisee, jamais le filtre JS.

Etape 9 : Lectures complémentaires

Ajoutez la persistance localStorage pour conserver les filtres entre visites, branchez un service worker pour le mode offline, et lisez nos guides JavaScript moderne pour debutants Dakar et deployer un site statique gratuit sur Netlify.

Partager