Cours Vibe coding avec l'IA · Module 4: Clone d'Instagram
Loading States et Skeletons - UX pour le chargement des données
Dans cette leçon7
L'équipage ouvre le fil et voit une page blanche pendant deux secondes. Certains pensent que l'appli est cassée, d'autres actualisent, et les données se rechargent depuis le début. Le coupable n'est pas la vitesse du serveur, mais l'absence d'information sur ce qui se passe. La solution : les états de chargement (loading states), un affichage distinct pendant la récupération, en cas d'erreur et quand il n'y a pas de données.
L'état de chargement pas à pas
La version la plus simple n'a besoin que d'un indicateur dans l'état :
1function PirateFeed() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(true);
4
5 useEffect(() => {
6 fetchPosts()
7 .then(data => setPosts(data))
8 .finally(() => setLoading(false));
9 }, []);
10
11 if (loading) {
12 return <div className="text-center p-8">Chargement...</div>;
13 }
14
15 return (
16 <div>
17 {posts.map(post => <PostCard key={post.id} post={post} />)}
18 </div>
19 );
20}Le cycle compte quatre étapes : tu mets loading à true avec useState(true), tu appelles fetchPosts() dans useEffect, dans .then tu enregistres les données avec setPosts(data), et dans .finally tu mets loading à false. .finally s'exécute toujours, après un succès comme après une erreur, donc l'écran de chargement ne reste pas indéfiniment. Mais le simple texte Chargement... ne dit ni ce qui va apparaître, ni combien de temps ça va durer.
Un squelette plutôt qu'un texte
Un squelette (skeleton) est le contour gris du contenu en cours de chargement : un cercle à la place de l'avatar, des barres à la place du texte, un rectangle à la place de la photo :
1function PostSkeleton() {
2 return (
3 <div className="border rounded-lg p-6 bg-white animate-pulse">
4 {/* Header */}
5 <div className="flex items-center gap-3 mb-4">
6 <div className="w-12 h-12 bg-gray-300 rounded-full" />
7 <div className="flex-1 space-y-2">
8 <div className="h-4 bg-gray-300 rounded w-32" />
9 <div className="h-3 bg-gray-200 rounded w-24" />
10 </div>
11 </div>
12
13 {/* Contenu */}
14 <div className="space-y-2 mb-4">
15 <div className="h-4 bg-gray-300 rounded" />
16 <div className="h-4 bg-gray-300 rounded w-5/6" />
17 </div>
18
19 {/* Image */}
20 <div className="h-64 bg-gray-300 rounded-lg mb-4" />
21
22 {/* Actions */}
23 <div className="flex gap-4">
24 <div className="h-8 w-16 bg-gray-300 rounded" />
25 <div className="h-8 w-16 bg-gray-300 rounded" />
26 </div>
27 </div>
28 );
29}Le squelette a les mêmes espacements et tailles que la vraie carte : quand les données arrivent, rien ne saute sur la page. Il ne contient aucune donnée, c'est juste un ensemble d'éléments div gris.
Le mouvement vient de la classe animate-pulse :
1<div className="animate-pulse bg-gray-300 h-10 rounded" />animate-pulse crée une animation pulsante qui imite le contenu en cours de chargement : toutes les deux secondes, l'élément pâlit jusqu'à mi-opacité puis revient. La couleur et la taille viennent d'autres classes, l'animation ne change que l'opacité.
Des squelettes dans le fil
Remplaçons maintenant le texte par trois squelettes :
1function PirateFeed() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(true);
4
5 useEffect(() => {
6 fetchPosts()
7 .then(data => setPosts(data))
8 .finally(() => setLoading(false));
9 }, []);
10
11 if (loading) {
12 return (
13 <div className="space-y-4">
14 <PostSkeleton />
15 <PostSkeleton />
16 <PostSkeleton />
17 </div>
18 );
19 }
20
21 return (
22 <div className="space-y-4">
23 {posts.map(post => <PostCard key={post.id} post={post} />)}
24 </div>
25 );
26}La logique de récupération n'a pas changé, seul l'affichage de la branche if (loading) est différent. Trois squelettes annoncent une liste, pas un élément unique.
Quand l'API renvoie une erreur
Le réseau peut tomber, et sans gestion d'erreur l'utilisateur verra une liste vide et croira qu'il n'y a pas de posts :
1function PirateFeed() {
2 const [posts, setPosts] = useState<Post[]>([]);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState<string | null>(null);
5
6 useEffect(() => {
7 fetchPosts()
8 .then(data => setPosts(data))
9 .catch(err => setError(err.message))
10 .finally(() => setLoading(false));
11 }, []);
12
13 if (loading) {
14 return <div className="space-y-4">
15 {[...Array(3)].map((_, i) => <PostSkeleton key={i} />)}
16 </div>;
17 }
18
19 if (error) {
20 return (
21 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
22 <p className="font-bold">Erreur lors du chargement des posts</p>
23 <p>{error}</p>
24 <button
25 onClick={() => window.location.reload()}
26 className="mt-2 underline"
27 >
28 Réessayer
29 </button>
30 </div>
31 );
32 }
33
34 return (
35 <div className="space-y-4">
36 {posts.map(post => <PostCard key={post.id} post={post} />)}
37 </div>
38 );
39}.catch(err => setError(err.message)) capture une erreur de la chaîne de promesses et enregistre le message dans l'état, que l'affichage montre avec un bouton Réessayer. [...Array(3)].map(...) est une façon plus courte d'obtenir trois squelettes. Le bouton recharge toute la page avec window.location.reload() ; une version plus douce rappellerait seulement fetchPosts().
L'état vide
Une réponse sans erreur ne contient pas forcément de données. Tu reconnais l'état vide (empty state) à la longueur du tableau :
1if (posts.length === 0) {
2 return (
3 <div className="text-center p-12">
4 <div className="text-6xl mb-4">◆</div>
5 <h2 className="text-2xl font-bold mb-2">Aucun post pour l'instant</h2>
6 <p className="text-gray-600 mb-4">
7 Sois le premier pirate à publier quelque chose !
8 </p>
9 <button className="bg-blue-600 text-white px-6 py-2 rounded">
10 Créer un post
11 </button>
12 </div>
13 );
14}Tu vérifies posts.length === 0 après avoir géré le chargement et l'erreur, car pendant la récupération le tableau est vide aussi. Un bon état vide suggère l'étape suivante, ici un bouton Créer un post.
Un bouton pendant l'envoi
Un bouton d'envoi a lui aussi besoin d'un état de chargement, sinon un pirate impatient clique trois fois et ajoute trois posts :
1function SubmitButton() {
2 const [loading, setLoading] = useState(false);
3
4 const handleClick = async () => {
5 setLoading(true);
6 await submitForm();
7 setLoading(false);
8 };
9
10 return (
11 <button
12 onClick={handleClick}
13 disabled={loading}
14 className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
15 >
16 {loading && (
17 <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
18 <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
19 <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
20 </svg>
21 )}
22 {loading ? 'Envoi en cours...' : 'Envoyer'}
23 </button>
24 );
25}disabled={loading} bloque les clics suivants, et animate-spin fait tourner l'icône. Mais si submitForm() lève une erreur, setLoading(false) ne s'exécute pas et le bouton reste bloqué pour toujours. Dans du vrai code, entoure l'appel d'un try et remets false dans le bloc finally.
Suspense
React a aussi un composant Suspense, qui affiche un fallback tant que le composant à l'intérieur n'est pas prêt :
1import { Suspense } from 'react';
2
3function App() {
4 return (
5 <Suspense fallback={<PostSkeleton />}>
6 <PirateFeed />
7 </Suspense>
8 );
9}Le piège : Suspense ne remarque pas les données récupérées dans useEffect, donc avec notre PirateFeed le squelette du fallback ne s'affichera pas, et c'est son propre état loading qui gère le chargement. Le fallback fonctionne avec React.lazy(), dans les Server Components de Next.js et avec les bibliothèques compatibles Suspense, comme React Query ou SWR. Dans Next.js, un fichier loading.tsx entoure automatiquement la page d'un Suspense.
Conseil du Capitaine Barbe-Rouge : quand tu demandes à l'IA un composant avec des données, nomme explicitement les quatre états : chargement, erreur, vide et succès. Les modèles oublient volontiers les deux du milieu, et ce sont justement eux qui évitent à l'équipage un écran blanc.
À retenir : toute vue avec des données a quatre états, et dans chacun l'utilisateur doit savoir ce qui se passe. L'éditeur ci-dessous montre encore le carrousel de trésors de la leçon sur la galerie, sans état de chargement : tu peux l'ajouter pour t'entraîner, avec loading et un squelette de carte gris avant l'affichage du trésor. Ensuite, fais les exercices.
Code de cette leçon : App.jsx
1// Carousel d'images - Navigation Prev/Next
2import { useState } from 'react';
3
4const treasures = [
5 { id: 1, emoji: '', name: 'Diamant des Caraïbes', value: '50 000 pièces d\'or' },
6 { id: 2, emoji: '', name: 'Couronne de Neptune', value: '100 000 pièces d\'or' },
7 { id: 3, emoji: '', name: 'Épée du Black Pearl', value: '25 000 pièces d\'or' },
8 { id: 4, emoji: '', name: 'Collier de la Sirène', value: '75 000 pièces d\'or' },
9 { id: 5, emoji: '', name: 'Coupe du Pirate', value: '150 000 pièces d\'or' },
10];
11
12export default function App() {
13 const [currentIndex, setCurrentIndex] = useState(0);
14
15 const nextPhoto = () => {
16 setCurrentIndex((prev) => (prev + 1) % treasures.length);
17 };
18
19 const prevPhoto = () => {
20 setCurrentIndex((prev) => (prev - 1 + treasures.length) % treasures.length);
21 };
22
23 const current = treasures[currentIndex];
24
25 return (
26 <div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 flex items-center justify-center p-8">
27 <div className="max-w-4xl w-full">
28 <h1 className="text-4xl font-bold text-white text-center mb-8">
29 Carousel des trésors
30 </h1>
31
32 <div className="relative">
33 {/* Bouton précédent */}
34 <button
35 onClick={prevPhoto}
36 className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-white/20 hover:bg-white/40 text-white text-5xl w-16 h-16 rounded-full flex items-center justify-center transition"
37 >
38 ‹
39 </button>
40
41 {/* Affichage du trésor */}
42 <div className="bg-gradient-to-br from-yellow-500 to-amber-700 rounded-lg p-12 text-center">
43 <div className="text-9xl mb-6">{current.emoji}</div>
44 <h2 className="text-3xl font-bold text-white mb-2">{current.name}</h2>
45 <p className="text-xl text-yellow-100 mb-4">Valeur : {current.value}</p>
46 <div className="flex items-center justify-center gap-2 mt-6">
47 {treasures.map((_, index) => (
48 <div
49 key={index}
50 className={`h-2 rounded-full transition-all ${
51 index === currentIndex
52 ? 'w-8 bg-white'
53 : 'w-2 bg-white/50'
54 }`}
55 />
56 ))}
57 </div>
58 </div>
59
60 {/* Bouton suivant */}
61 <button
62 onClick={nextPhoto}
63 className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-white/20 hover:bg-white/40 text-white text-5xl w-16 h-16 rounded-full flex items-center justify-center transition"
64 >
65 ›
66 </button>
67 </div>
68
69 <p className="text-center text-gray-400 mt-6">
70 {currentIndex + 1} / {treasures.length}
71 </p>
72 </div>
73 </div>
74 );
75}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que fait la classe animate-pulse dans Tailwind lors de la construction de squelettes ?
2. Comment captures-tu une erreur lors de la récupération de données depuis une API pour pouvoir afficher un message ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les étapes de récupération de données depuis une API avec un état de chargement :