Afficher le classement avec FlatList
Appeler la fonction
meilleurs_scoresdepuis l'application et afficher les meilleures parties dans une liste filtrable.
Objectifs de la séance
- appeler une fonction SQL (RPC) depuis un service et convertir ses lignes avec vérification ;
- écrire un hook
useClassementqui évite les réponses dans le désordre et permet d'actualiser ; - afficher une liste avec
FlatList(data,renderItem,keyExtractor, séparateurs, liste vide) ; - ajouter des filtres (thème et niveau) dont les valeurs initiales viennent de l'adresse de l'écran ;
- relier le nouvel écran à l'accueil et à l'écran de résultat.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- dans l'éditeur SQL de Supabase, exécuter
select * from public.meilleurs_scores(null, null, 5);et lire les noms des colonnes renvoyées - exécuter
select * from public.meilleurs_scores('reseaux', 1, 5);et vérifier que seules des parties « Réseaux et Wi-Fi » en niveau Débutant apparaissent (par exemple, celle que vous avez jouée au tutoriel précédent) - exécuter
select count(*) from public.meilleurs_scores(null, null, 100000);et dire si le résultat peut dépasser 50
📌 Réponse attendue
TP pour réfléchir et résoudre des problèmes
Votre mission : créer l'écran Classement. Il appelle la fonction SQL, affiche une ligne par partie (rang,
pseudo, score, temps) et propose des filtres. On l'atteint depuis l'accueil, et depuis l'écran de résultat
(avec le thème et le niveau de la partie qui vient de se terminer). Le serveur de développement doit être lancé
(npx expo start, touche w). Vous êtes toujours sur la branche feature/classement.
Étape 1 — Décrire une ligne du classement
Dans src/types/quiz.ts, ajoutez à la fin du fichier le type d'une ligne du classement. Les noms
suivent la convention de l'application (camelCase), alors que la base utilise snake_case.
Complétez la propriété qui contient la durée de la partie en secondes :
Le type LigneClassement décrit une ligne ; la liste complète sera un LigneClassement[]. Le jour où la
fonction SQL gagne une colonne, TypeScript vous indique tous les endroits à modifier.
Étape 2 — Convertir une ligne reçue du serveur
Une fonction SQL renvoie du JSON « brut » (type unknown) : on ne le suppose jamais correct. Dans
src/logic/conversions.ts, ajoutez LigneClassement à l'import des types (en haut du fichier, par ordre alphabétique), puis
ajoutez à la fin du fichier la fonction de conversion. Complétez la vérification de nb_questions :
Ajoutez aussi un test à la fin de tests/conversions.test.ts (sans oublier d'ajouter convertirLigneClassement à
la liste des fonctions importées en haut du fichier), puis lancez npm test :
test("convertirLigneClassement transforme une ligne du classement", () => {
const ligne = convertirLigneClassement({
pseudo: "Bob",
score: 3,
nb_questions: 5,
duree_secondes: 40,
terminee_le: "2026-10-26T09:06:32+00:00",
});
assert.equal(ligne?.nbQuestions, 5);
assert.equal(convertirLigneClassement({ pseudo: "Bob" }), null);
});
Une ligne mal formée est convertie en null et sera ignorée par le service : le classement
s'affiche sans elle, au lieu de faire planter tout l'écran. La même idée a été appliquée aux questions et aux thèmes.
Étape 3 — Appeler la fonction depuis un service
Créez src/services/classementService.ts. Comme tous les services, c'est le seul fichier qui parle à
Supabase pour le classement. Complétez le paramètre qui transmet le thème choisi (le mot "tous" de
l'application devient null pour le serveur) :
Le détail technique (error.code, error.message) va dans la console, pour vous. Le joueur ne voit qu'une
phrase simple : « Impossible de charger le classement. » Il n'a pas besoin de connaître le nom de vos tables.
Étape 4 — Écrire le hook useClassement
Créez src/hooks/useClassement.ts. Il reprend le principe de useNombreQuestionsDisponibles : une requête annulable
et un résultat mémorisé avec sa clé, pour ne jamais afficher le classement d'anciens filtres. Une nouveauté :
la clé contient aussi un numéro de tentative, que le bouton « Actualiser » incrémente pour relancer le chargement.
Complétez la clé :
Complétez maintenant la fonction actualiser. Elle augmente la tentative de 1, avec une mise à jour
fonctionnelle (la valeur précédente est fournie par React) :
Le hook ne stocke pas « chargement » : il le déduit (dernierResultat absent, ou clé différente). Dès que les
filtres changent, la clé change, et l'écran repasse tout seul en « chargement » sans aucun setState supplémentaire.
Étape 5 — Dessiner une ligne du classement
Créez src/components/LigneClassementVue.tsx : une ligne affiche le rang, le pseudo, le score (3/5) et le
temps. Complétez la ligne qui affiche la durée avec formaterDuree (la fonction écrite au tutoriel 4) :
numberOfLines={1} coupe le texte avec « ... » au lieu de le passer sur deux lignes, et flex: 1 donne au
pseudo toute la place restante entre le rang et le score.
Étape 6 — Afficher la liste avec FlatList
Créez src/app/classement.tsx. Écrivez d'abord le début : les imports, puis la lecture des filtres (étape 7), puis
le corps de l'écran. L'écran distingue les trois états du chargement : chargement, erreur (avec un bouton
« Réessayer ») et succès, où la liste apparaît. Complétez la propriété qui fournit une clé unique à chaque ligne :
Complétez maintenant la propriété qui affiche un message lorsque la liste est vide (aucune partie pour les filtres choisis) :
Un classement vide est un cas normal (premier joueur, filtre rare). Un message clair vaut mieux qu'un écran blanc, qui laisse croire à un bogue.
Étape 7 — Ajouter les filtres et lire les paramètres initiaux
L'écran doit pouvoir s'ouvrir avec un thème et un niveau d éjà choisis (depuis l'écran de résultat). Les
fonctions de lecture validée lireTheme, lireNiveau et premiereValeur, écrites au tutoriel 5 dans
src/logic/parametresPartie.ts, n'étaient utilisées que dans ce fichier : il faut les exporter. Complétez le
mot-clé qui rend lireTheme utilisable depuis un autre fichier :
Dans l'écran, les filtres sont deux états (useState). Leur valeur initiale vient de l'adresse
(/classement?theme=...&niveau=...), validée par lireTheme ; si le paramètre est absent ou falsifié, on prend
"tous". Complétez le calcul de la valeur initiale du thème :
Une adresse peut être saisie à la main (sur le Web) : /classement?theme=pirate ne doit pas casser l'écran.
lireTheme renvoie null pour un thème inconnu, et ?? "tous" retombe sur un choix valide.
Voici l'écran complet. Les boutons de filtres réutilisent ChoixOption (du générateur) et la liste des
niveaux CHOIX_NIVEAUX ; les thèmes viennent de useThemes, déjà utilisé par le générateur :
import { useLocalSearchParams } from "expo-router";
import { useState } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BoutonPrincipal from "@/components/BoutonPrincipal";
import Chargement from "@/components/Chargement";
import ChoixOption from "@/components/ChoixOption";
import LigneClassementVue from "@/components/LigneClassementVue";
import MessageErreur from "@/components/MessageErreur";
import { COULEURS } from "@/constants/couleurs";
import { CHOIX_NIVEAUX } from "@/constants/generateur";
import { useClassement } from "@/hooks/useClassement";
import { useThemes } from "@/hooks/useThemes";
import { lireNiveau, lireTheme, premiereValeur } from "@/logic/parametresPartie";
import type { FiltreNiveau, FiltreTheme } from "@/types/quiz";
// Écran du classement (route "/classement?theme=...&niveau=...")
export default function EcranClassement() {
// Filtres initiaux facultatifs (ex. : depuis l'écran de résultat)
const parametres = useLocalSearchParams<{ theme?: string; niveau?: string }>();
const [theme, setTheme] = useState<FiltreTheme>(
() => lireTheme(premiereValeur(parametres.theme)) ?? "tous",
);
const [niveau, setNiveau] = useState<FiltreNiveau>(
() => lireNiveau(premiereValeur(parametres.niveau)) ?? "tous",
);
const { etatThemes } = useThemes();
const { etatClassement, actualiser } = useClassement(theme, niveau);
return (
<SafeAreaView style={styles.ecran} edges={["bottom"]}>
<View style={styles.contenu}>
<View style={styles.ligne}>
<ChoixOption
libelle="Tous les thèmes"
estSelectionne={theme === "tous"}
onPress={() => setTheme("tous")}
/>
{etatThemes.statut === "succes" &&
etatThemes.donnees.map((t) => (
<ChoixOption
key={t.code}
libelle={t.libelle}
estSelectionne={theme === t.code}
onPress={() => setTheme(t.code)}
/>
))}
</View>
<View style={styles.ligne}>
{CHOIX_NIVEAUX.map((choix) => (
<ChoixOption
key={choix.libelle}
libelle={choix.libelle}
estSelectionne={niveau === choix.valeur}
onPress={() => setNiveau(choix.valeur)}
/>
))}
</View>
{etatClassement.statut === "chargement" && (
<Chargement message="Chargement du classement..." />
)}
{etatClassement.statut === "erreur" && (
<MessageErreur
message={etatClassement.message}
texteBouton="Réessayer"
onPress={actualiser}
/>
)}
{etatClassement.statut === "succes" && (
// FlatList : liste performante, n'affiche que les lignes visibles
<FlatList
data={etatClassement.donnees}
// La liste est remplacée en entier à chaque chargement :
// le rang (index) suffit comme clé unique
keyExtractor={(_ligne, index) => String(index)}
renderItem={({ item, index }) => (
<LigneClassementVue rang={index + 1} ligne={item} />
)}
ItemSeparatorComponent={() => <View style={styles.separateur} />}
ListEmptyComponent={
<Text style={styles.vide}>Aucune partie pour ces réglages : soyez le premier !</Text>
}
style={styles.liste}
/>
)}
<BoutonPrincipal
texte="Actualiser"
onPress={actualiser}
desactive={etatClassement.statut === "chargement"}
/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
ecran: {
flex: 1,
backgroundColor: COULEURS.fond,
},
contenu: {
flex: 1,
padding: 16,
gap: 12,
width: "100%",
maxWidth: 600,
alignSelf: "center",
},
ligne: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
liste: {
flex: 1,
},
separateur: {
height: 8,
},
vide: {
fontSize: 16,
color: COULEURS.texteSecondaire,
textAlign: "center",
padding: 24,
},
});
Étape 8 — Relier l'écran à l'application
Trois petites modifications. D'abord, déclarez l'écran dans src/app/_layout.tsx, à la suite des autres :
Ensuite, dans src/app/index.tsx, ajoutez un bouton Classement juste après « Créer un quiz » (dans le bloc
affiché quand le joueur a un pseudo) :
<BoutonPrincipal
texte="Classement"
onPress={() => router.push("/classement")}
/>
Enfin, dans src/components/ResultatServeur.tsx, ajoutez un bouton Voir le classement entre « Rejouer » et
« Nouveau quiz ». Il ouvre le classement avec le thème et le niveau de la partie terminée. Les paramètres
d'adresse sont toujours des chaînes : le niveau (qui peut être un nombre) est converti avec String.
Expo Router connaît la liste de vos écrans grâce à des types générés automatiquement. Si l'éditeur souligne
"/classement" en rouge, relancez npx expo start : le fichier de types est recalculé et l'erreur disparaît.
Étape 9 — Tester l'écran
Lancez l'application, choisissez un pseudo si besoin, puis :
- cliquez sur Classement depuis l'accueil : la liste affiche vos parties (« Tous les thèmes », « Tous ») ;
- choisissez « Réseaux et Wi-Fi » et « Débutant » : seules les parties correspondantes restent ;
- changez très vite de filtre plusieurs fois : la liste affichée correspond toujours au dernier filtre choisi ;
- jouez une partie, puis cliquez sur Voir le classement depuis l'écran de résultat : les filtres sont déjà réglés sur le thème et le niveau de la partie ;
- ouvrez à la main
/classement?theme=pirate&niveau=9dans le navigateur : l'écran s'affiche avec « Tous ».
Résultat attendu (une seule partie jouée en Réseaux et Wi-Fi, niveau Débutant) :

Vérifiez enfin que le code est correct, puis enregistrez votre travail :
npx tsc --noEmit
npm test
Changer vite de filtre (étape 3 du test) et ouvrir une adresse falsifiée (étape 5) sont des tests de robustesse : ils vérifient les deux protections de cette séance (la clé du résultat et la validation du paramètre).
Étape 10 — Fusionner la branche du tutoriel
Le tutoriel est terminé : sur GitHub, ouvrez une Pull Request de feature/classement vers main (bouton
Compare & pull request, qui apparaît en haut de la page du dépôt), relisez les fichiers modifiés (onglet
Files changed), puis cliquez sur Merge pull request. Revenez ensuite sur main en local et récupérez la fusion :
La Pull Request est le moment de relire son travail comme si c'était celui d'un autre. Une fois fusionnée,
la branche feature/classement ne sert plus : vous pouvez la supprimer (bouton Delete branch sur GitHub).
📌 Une solution
Notions théoriques
FlatList : afficher une longue liste efficacement
Au tutoriel 3, vous avez affiché une liste avec map : toutes les lignes sont créées d'un coup. C'est
parfait pour 4 réponses, mais un classement peut compter des centaines de lignes. FlatList est le composant
fait pour cela : il ne crée que les lignes visibles à l'écran (et quelques-unes autour), et en crée de
nouvelles au fur et à mesure du défilement.
| Propriété | Rôle |
|---|---|
data | le tableau des éléments à afficher |
renderItem | une fonction qui reçoit { item, index } et renvoie le composant d'une ligne |
keyExtractor | une fonction qui renvoie une clé unique (texte) pour chaque ligne |
ItemSeparatorComponent | le composant affiché entre deux lignes |
ListEmptyComponent | le composant affiché quand data est vide |
FlatList défile toute seule : il ne faut donc pas la placer dans un ScrollView. Pour qu'elle occupe
l'espace restant de l'écran (et pousse le bouton « Actualiser » en bas), on lui donne flex: 1.
La clé d'une ligne
Pour retrouver quelles lignes ont changé, React a besoin d'une clé unique et stable (voir key au tutoriel 3).
Dans les listes de thèmes, on utilisait l'identifiant (t.code). Ici, une ligne du classement n'a pas
d'identifiant (on a volontairement refusé de renvoyer l'uuid) : on utilise donc son rang, String(index).
C'est acceptable parce que la liste est remplacée en entier à chaque chargement et qu'elle n'est ni triée ni modifiée
côté application. Pour une liste que l'utilisateur peut réordonner ou dont on supprime des éléments, l'index serait un mauvais choix.
Trois états et un résultat mémorisé avec sa clé
Le hook useClassement applique les patrons vus au tutoriel 7 :
- l'état
EtatChargement(chargement, erreur, succès) évite d'afficher deux choses à la fois ; AbortControllerannule la requête de l'ancien filtre quand le joueur change de filtre ou quitte l'écran ;- la clé (
"reseaux/1/0") accompagne le résultat mémorisé : si elle ne correspond plus aux filtres actuels, l'écran affiche « chargement » plutôt qu'un classement périmé ; - le numéro de tentative, dans la clé, permet de forcer un nouveau chargement (« Actualiser », « Réessayer ») sans changer de filtre.
Paramètres initiaux d'un écran
Un écran peut recevoir des paramètres d'adresse (/classement?theme=reseaux&niveau=1). Il les lit avec
useLocalSearchParams, qui renvoie des chaînes (ou undefined). Comme pour l'écran de partie, on ne leur fait pas confiance :
lireTheme et lireNiveau valident la valeur, et on retombe sur "tous" si elle est absente ou fausse. La valeur
initiale n'est calculée qu'une fois, grâce à la fonction passée à useState(() => ...) : ensuite, c'est le joueur qui
décide en cliquant sur les filtres.
Test de mémorisation/compréhension
Exercices complémentaires
Exercice A : mettre en avant le podium
Dans LigneClassementVue, affichez le rang d'un autre style pour les 3 premiers (par exemple en orange,
avec COULEURS ou une couleur de votre choix). Aide : un style supplémentaire dans StyleSheet.create, appliqué
avec rang <= 3 && styles.podium.
Exercice B : la date de la partie
La ligne du classement contient termineeLe (une date au format ISO) que l'écran n'affiche pas. Affichez-la sous
le pseudo au format 26/10/2026. Aide : new Date(ligne.termineeLe).toLocaleDateString("fr-FR").
Exercice C : tirer pour actualiser
FlatList accepte refreshing (un booléen) et onRefresh (une fonction) : c'est le geste « tirer vers le bas pour
actualiser » des téléphones. Branchez-les sur etatClassement.statut === "chargement" et sur actualiser.
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
supabase.rpc("nom", { ... }) | Appelle une fonction SQL ; les noms des paramètres sont ceux de la fonction (p_...). |
| Conversion vérifiée | Les lignes reçues sont de type unknown : on les vérifie, et on écarte celles qui sont incorrectes. |
FlatList | Liste performante qui ne crée que les lignes visibles : data, renderItem, keyExtractor. |
ItemSeparatorComponent, ListEmptyComponent | Séparateur entre les lignes et message de liste vide. |
Clé du résultat + AbortController | Empêchent d'afficher le classement d'anciens filtres. |
| Numéro de tentative | Dans la clé, il force un nouveau chargement sans changer les filtres. |
| Paramètres initiaux validés | useLocalSearchParams + lireTheme/lireNiveau + valeur par défaut "tous". |
| Pull Request | Fin de tutoriel : relecture, fusion dans main, puis git switch main et git pull. |
Aperçu de la prochaine séance
Votre application est fonctionnelle : on peut s'inscrire, jouer, obtenir un score fiable et se comparer aux autres. Avant de la montrer au monde, il faut s'assurer qu'elle résiste : réseau coupé, double clic, adresse falsifiée, clé oubliée dans le code... Dans le tutoriel suivant, vous allez rédiger un cahier de recette, lancer des vérifications automatiques, puis publier l'application sur le Web et en construire une version Android.