Cours Vibe coding avec l'IA · Module 4: Clone d'Instagram
State avancé - Système de commentaires et de likes
Dans cette leçon5
La galerie ressemble déjà à Instagram, mais elle est inerte : impossible de liker une photo ou de commenter quoi que ce soit. L'équipage veut réagir au butin, et chaque réaction doit atteindre le bon post, pas tous à la fois. Dans cette leçon, tu organises l'état de l'application : une liste de posts avec likes et commentaires, un champ de commentaire et un formulaire d'ajout de photo.
La forme des données
Avant que quoi que ce soit ne réagisse aux clics, mettons-nous d'accord sur ce qu'est un commentaire et un post dans le code. Un post garde ses commentaires dans un tableau :
1interface Comment {
2 id: number;
3 author: string;
4 avatar: string;
5 text: string;
6 timestamp: Date;
7}
8
9interface Post {
10 id: number;
11 imageUrl: string;
12 caption: string;
13 author: string;
14 avatar: string;
15 likes: number;
16 isLiked: boolean;
17 comments: Comment[];
18 timestamp: Date;
19}likes est le nombre de likes, et isLiked indique si c'est toi qui as liké le post. Les commentaires n'ont pas d'état séparé : ils font partie du post, donc ajouter un commentaire revient à modifier un post de la liste.
Le composant du post
Un post Instagram a un ordre de sections fixe, de haut en bas : en-tête (auteur, avatar), photo, actions (like, commentaire, partage), puis légende et commentaires. Le composant le reproduit à l'identique :
1'use client';
2
3import { useState } from 'react';
4import Image from 'next/image';
5
6interface PostProps {
7 post: Post;
8 onLike: (postId: number) => void;
9 onAddComment: (postId: number, comment: string) => void;
10}
11
12export default function InstagramPost({ post, onLike, onAddComment }: PostProps) {
13 const [commentText, setCommentText] = useState('');
14 const [showAllComments, setShowAllComments] = useState(false);
15
16 const handleSubmitComment = (e: React.FormEvent) => {
17 e.preventDefault();
18 if (commentText.trim()) {
19 onAddComment(post.id, commentText);
20 setCommentText('');
21 }
22 };
23
24 const displayedComments = showAllComments
25 ? post.comments
26 : post.comments.slice(0, 2);
27
28 return (
29 <div className="bg-white border rounded-lg mb-4 max-w-2xl mx-auto">
30 {/* Header */}
31 <div className="flex items-center gap-3 p-4">
32 <div className="text-2xl">{post.avatar}</div>
33 <div className="flex-1">
34 <h3 className="font-bold text-sm">{post.author}</h3>
35 <p className="text-xs text-gray-500">
36 {post.timestamp.toLocaleDateString('fr-FR')}
37 </p>
38 </div>
39 <button className="text-gray-600 hover:text-gray-800">•••</button>
40 </div>
41
42 {/* Image */}
43 <div className="relative aspect-square">
44 <Image
45 src={post.imageUrl}
46 alt={post.caption}
47 fill
48 className="object-cover"
49 />
50 </div>
51
52 {/* Actions */}
53 <div className="p-4">
54 <div className="flex items-center gap-4 mb-3">
55 <button
56 onClick={() => onLike(post.id)}
57 className={`text-sm font-semibold transition-transform hover:scale-110 ${
58 post.isLiked ? 'text-red-500' : 'text-gray-700'
59 }`}
60 >
61 {post.isLiked ? 'Aimé' : 'J’aime'}
62 </button>
63 <button className="text-sm font-semibold hover:scale-110 transition-transform">
64 Commenter
65 </button>
66 <button className="text-sm font-semibold hover:scale-110 transition-transform">
67 Partager
68 </button>
69 <button className="ml-auto text-sm font-semibold hover:scale-110 transition-transform">
70 Enregistré
71 </button>
72 </div>
73
74 {/* Nombre de likes */}
75 <p className="font-bold text-sm mb-2">{post.likes} likes</p>
76
77 {/* Légende */}
78 <p className="text-sm mb-2">
79 <span className="font-bold">{post.author}</span> {post.caption}
80 </p>
81
82 {/* Commentaires */}
83 <div className="space-y-2">
84 {post.comments.length > 2 && !showAllComments && (
85 <button
86 onClick={() => setShowAllComments(true)}
87 className="text-sm text-gray-500 hover:text-gray-700"
88 >
89 Voir tous les commentaires ({post.comments.length})
90 </button>
91 )}
92
93 {displayedComments.map((comment) => (
94 <div key={comment.id} className="text-sm">
95 <span className="font-bold">{comment.author}</span>{' '}
96 {comment.text}
97 </div>
98 ))}
99 </div>
100
101 {/* Ajouter un commentaire */}
102 <form onSubmit={handleSubmitComment} className="mt-3 flex items-center gap-2 border-t pt-3">
103 <input
104 type="text"
105 value={commentText}
106 onChange={(e) => setCommentText(e.target.value)}
107 placeholder="Ajoute un commentaire..."
108 className="flex-1 outline-none text-sm"
109 />
110 <button
111 type="submit"
112 disabled={!commentText.trim()}
113 className="text-blue-500 font-bold text-sm disabled:text-blue-300 disabled:cursor-not-allowed"
114 >
115 Publier
116 </button>
117 </form>
118 </div>
119 </div>
120 );
121}Le composant gère deux états indépendants en même temps : commentText garde le texte saisi dans le champ, et showAllComments décide si tous les commentaires sont visibles. Le champ est un input contrôlé : value vient de l'état, et onChange met à jour commentText via setCommentText à chaque frappe. Grâce à cela, disabled={!commentText.trim()} bloque le bouton Publier tant que le champ est vide et le débloque dès qu'il y a du texte.
handleSubmitComment fonctionne en quatre étapes : e.preventDefault() empêche le rechargement de page par défaut du formulaire, trim() vérifie que le texte n'est pas vide, puis on appelle onAddComment(post.id, commentText) et on vide le champ avec setCommentText(''). Le bouton Voir tous les commentaires apparaît de façon conditionnelle, grâce à {post.comments.length > 2 && ...}, donc seulement s'il y a plus de deux commentaires.
Un fil qui gère les posts
L'état de la liste des posts vit un niveau plus haut, dans le fil. Un post reçoit des données et deux fonctions, onLike et onAddComment :
1'use client';
2
3import { useState } from 'react';
4import InstagramPost from './InstagramPost';
5
6export default function TreasureFeed() {
7 const [posts, setPosts] = useState<Post[]>([
8 {
9 id: 1,
10 imageUrl: '/treasure-1.jpg',
11 caption: 'J\'ai trouvé un énorme trésor sur l\'île !',
12 author: 'Captain Redbeard',
13 avatar: '',
14 likes: 42,
15 isLiked: false,
16 comments: [
17 {
18 id: 1,
19 author: 'Morgan',
20 avatar: '',
21 text: 'Incroyable ! Où l\'as-tu trouvé ?',
22 timestamp: new Date(),
23 },
24 ],
25 timestamp: new Date(),
26 },
27 ]);
28
29 const handleLike = (postId: number) => {
30 setPosts(posts.map(post =>
31 post.id === postId
32 ? {
33 ...post,
34 likes: post.isLiked ? post.likes - 1 : post.likes + 1,
35 isLiked: !post.isLiked,
36 }
37 : post
38 ));
39 };
40
41 const handleAddComment = (postId: number, commentText: string) => {
42 setPosts(posts.map(post =>
43 post.id === postId
44 ? {
45 ...post,
46 comments: [
47 ...post.comments,
48 {
49 id: Date.now(),
50 author: 'You',
51 avatar: '',
52 text: commentText,
53 timestamp: new Date(),
54 },
55 ],
56 }
57 : post
58 ));
59 };
60
61 return (
62 <div className="min-h-screen bg-gray-50 py-8">
63 <div className="max-w-2xl mx-auto">
64 <h1 className="text-2xl font-bold mb-6 px-4">Treasure Feed</h1>
65
66 {posts.map(post => (
67 <InstagramPost
68 key={post.id}
69 post={post}
70 onLike={handleLike}
71 onAddComment={handleAddComment}
72 />
73 ))}
74 </div>
75 </div>
76 );
77}On met à jour un like avec setPosts(posts.map(...)). map parcourt le tableau, crée un nouvel objet avec le spread ({ ...post }) pour le post dont l'id correspond, et renvoie les autres sans changement. Si le post était déjà liké, likes diminue de 1 et isLiked devient false. On ajoute un commentaire de la même façon : [...post.comments, newComment] construit un nouveau tableau avec l'entrée ajoutée à la fin. Écrire posts[id].likes++ ne marche pas, car React ne remarque pas un changement dans le même tableau.
Les posts s'affichent avec key={post.id}. La clé doit être unique et stable, c'est pourquoi on prend l'id plutôt qu'un nombre aléatoire ou une légende qui peut se répéter.
Ce changement immédiat, c'est l'UI optimiste : l'écran se met à jour tout de suite, sans attendre la réponse du serveur, et la requête vers l'API part en arrière-plan. React 19 propose pour cela le hook useOptimistic, qui revient à l'état précédent si l'enregistrement échoue.
Un bouton like dans un seul composant
Quand un bouton like a son propre état, bascule-le avec une fonction qui reçoit la valeur précédente :
1const [isLiked, setIsLiked] = useState(false);
2
3const toggleLike = () => setIsLiked(prev => !prev);prev => !prev inverse toujours la valeur actuelle, même si l'état change plusieurs fois en un clic. Écrire isLiked = !isLiked ne marche pas, car on ne modifie pas l'état par une affectation.
Le formulaire d'ajout de photo
Dernier élément : un bouton rond avec un plus, qui ouvre le formulaire dans une modale :
1'use client';
2
3import { useState } from 'react';
4
5interface UploadFormProps {
6 onUpload: (caption: string, imageUrl: string) => void;
7}
8
9export default function UploadPhotoForm({ onUpload }: UploadFormProps) {
10 const [caption, setCaption] = useState('');
11 const [previewUrl, setPreviewUrl] = useState('');
12 const [isOpen, setIsOpen] = useState(false);
13
14 // Envoi simulé - dans une vraie app tu utiliserais FileReader ou un upload vers un serveur
15 const handleImageSelect = () => {
16 // Pour la démo, on utilise une image aléatoire d'Unsplash
17 const randomId = Math.floor(Math.random() * 1000);
18 setPreviewUrl(`https://picsum.photos/seed/${randomId}/600/600`);
19 };
20
21 const handleSubmit = (e: React.FormEvent) => {
22 e.preventDefault();
23 if (caption && previewUrl) {
24 onUpload(caption, previewUrl);
25 setCaption('');
26 setPreviewUrl('');
27 setIsOpen(false);
28 }
29 };
30
31 return (
32 <>
33 <button
34 onClick={() => setIsOpen(true)}
35 className="fixed bottom-8 right-8 bg-blue-600 text-white w-16 h-16 rounded-full shadow-lg hover:bg-blue-700 transition-colors text-2xl"
36 >
37 +
38 </button>
39
40 {isOpen && (
41 <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
42 <div className="bg-white rounded-lg max-w-md w-full p-6">
43 <h2 className="text-xl font-bold mb-4">Ajouter une nouvelle photo</h2>
44
45 <form onSubmit={handleSubmit} className="space-y-4">
46 {!previewUrl ? (
47 <button
48 type="button"
49 onClick={handleImageSelect}
50 className="w-full h-64 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center hover:border-blue-500 transition-colors"
51 >
52 <div className="text-center">
53 <div className="text-4xl mb-2">◆</div>
54 <p className="text-gray-600">Clique pour sélectionner une photo</p>
55 </div>
56 </button>
57 ) : (
58 <div className="relative aspect-square rounded-lg overflow-hidden">
59 <img src={previewUrl} alt="Aperçu" className="w-full h-full object-cover" />
60 <button
61 type="button"
62 onClick={() => setPreviewUrl('')}
63 className="absolute top-2 right-2 bg-white rounded-full w-8 h-8 flex items-center justify-center shadow-lg"
64 >
65 ×
66 </button>
67 </div>
68 )}
69
70 <textarea
71 value={caption}
72 onChange={(e) => setCaption(e.target.value)}
73 placeholder="Écris une légende..."
74 className="w-full p-3 border rounded-lg resize-none outline-none focus:ring-2 focus:ring-blue-500"
75 rows={3}
76 />
77
78 <div className="flex gap-2">
79 <button
80 type="button"
81 onClick={() => {
82 setIsOpen(false);
83 setPreviewUrl('');
84 setCaption('');
85 }}
86 className="flex-1 px-4 py-2 border rounded-lg hover:bg-gray-50"
87 >
88 Annuler
89 </button>
90 <button
91 type="submit"
92 disabled={!caption || !previewUrl}
93 className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
94 >
95 Publier
96 </button>
97 </div>
98 </form>
99 </div>
100 </div>
101 )}
102 </>
103 );
104}L'envoi est simulé : handleImageSelect tire une image au hasard sur picsum.photos, et l'aperçu utilise un simple <img>, car le composant Image exigerait d'ajouter ce domaine à remotePatterns. Le bouton Publier reste inactif tant qu'il manque la légende ou la photo, et onUpload remonte les données du nouveau post, comme onLike.
Conseil du Capitaine Barbe-Rouge : l'état du navigateur disparaît au rechargement de la page. Enregistre aussi les likes et les commentaires en base, et traite l'UI optimiste comme un signal du nid-de-pie : d'abord tu montres, ensuite tu confirmes.
À retenir : garde l'état de la liste à un seul endroit et modifie-le avec un nouveau tableau construit par map et le spread. Dans l'éditeur ci-dessous, tu as un fil avec deux posts : like-en un, ajoute un commentaire et trouve handleLike et handleComment dans le code, puis fais les exercices.
Code de cette leçon : App.jsx
1// Feed complet façon Instagram
2import { useState } from 'react';
3
4const initialPosts = [
5 {
6 id: 1,
7 author: 'Captain Redbeard',
8 avatar: '',
9 image: '',
10 caption: 'J\'ai trouvé un énorme diamant !',
11 likes: 156,
12 isLiked: false,
13 comments: [
14 { id: 1, author: 'Morgan', text: 'Wow ! Où ça ?' },
15 ],
16 },
17 {
18 id: 2,
19 author: 'Morgan Blackheart',
20 avatar: '',
21 image: '',
22 caption: 'Nouvelle carte au trésor !',
23 likes: 89,
24 isLiked: false,
25 comments: [],
26 },
27];
28
29function Post({ post, onLike, onComment }) {
30 const [commentText, setCommentText] = useState('');
31
32 const handleSubmit = (e) => {
33 e.preventDefault();
34 if (commentText.trim()) {
35 onComment(post.id, commentText);
36 setCommentText('');
37 }
38 };
39
40 return (
41 <div className="bg-white rounded-lg shadow mb-4">
42 <div className="flex items-center gap-3 p-4">
43 <div className="text-2xl">{post.avatar}</div>
44 <h3 className="font-bold text-sm">{post.author}</h3>
45 </div>
46
47 <div className="aspect-square bg-gradient-to-br from-yellow-400 to-amber-600 flex items-center justify-center">
48 <div className="text-9xl">{post.image}</div>
49 </div>
50
51 <div className="p-4">
52 <div className="flex items-center gap-4 mb-2">
53 <button
54 onClick={() => onLike(post.id)}
55 className={`text-sm font-semibold ${post.isLiked ? 'text-red-500' : ''}`}
56 >
57 {post.isLiked ? 'Aimé' : 'J’aime'}
58 </button>
59 <button className="text-sm font-semibold">Commenter</button>
60 </div>
61
62 <p className="font-bold text-sm mb-1">{post.likes} likes</p>
63 <p className="text-sm mb-2">
64 <span className="font-bold">{post.author}</span> {post.caption}
65 </p>
66
67 {post.comments.map((c) => (
68 <div key={c.id} className="text-sm">
69 <span className="font-bold">{c.author}</span> {c.text}
70 </div>
71 ))}
72
73 <form onSubmit={handleSubmit} className="flex gap-2 mt-2 pt-2 border-t">
74 <input
75 value={commentText}
76 onChange={(e) => setCommentText(e.target.value)}
77 placeholder="Commenter..."
78 className="flex-1 outline-none text-sm"
79 />
80 <button
81 type="submit"
82 disabled={!commentText.trim()}
83 className="text-blue-500 font-bold text-sm"
84 >
85 Envoyer
86 </button>
87 </form>
88 </div>
89 </div>
90 );
91}
92
93export default function App() {
94 const [posts, setPosts] = useState(initialPosts);
95
96 const handleLike = (postId) => {
97 setPosts(posts.map((p) =>
98 p.id === postId
99 ? { ...p, likes: p.isLiked ? p.likes - 1 : p.likes + 1, isLiked: !p.isLiked }
100 : p
101 ));
102 };
103
104 const handleComment = (postId, text) => {
105 setPosts(posts.map((p) =>
106 p.id === postId
107 ? {
108 ...p,
109 comments: [
110 ...p.comments,
111 { id: Date.now(), author: 'You', text },
112 ],
113 }
114 : p
115 ));
116 };
117
118 return (
119 <div className="min-h-screen bg-gray-100 p-4">
120 <div className="max-w-2xl mx-auto">
121 <h1 className="text-3xl font-bold mb-6">Treasure Gallery</h1>
122 {posts.map((post) => (
123 <Post
124 key={post.id}
125 post={post}
126 onLike={handleLike}
127 onComment={handleComment}
128 />
129 ))}
130 </div>
131 </div>
132 );
133}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. Comment mets-tu à jour les likes d'un post spécifique dans un tableau ?
2. Quelles deux propriétés constituent un input contrôlé en React ?
Voici 2 des 12 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour ouvrir la modale en définissant la photo sélectionnée dans l'état :
- Tri horizontal
Ordonne les fragments de l'expression qui crée un nouveau tableau de commentaires avec une entrée ajoutée :
- Éditeur de code
Bouton like interactif
- Tri vertical
Ordonne les étapes de la gestion de la soumission du formulaire de commentaire (handleSubmitComment) :
- Éditeur de code
Système de commentaires
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour commencer à afficher la liste des photos dans la grille :
- Tri vertical
Ordonne la logique de handleLike qui met à jour un post spécifique dans le tableau :
- Tri vertical
Ordonne les sections d'un post Instagram dans le bon ordre (de haut en bas) :
- Éditeur de code
Post Instagram complet
- Tri horizontal
Ordonne les fragments de l'expression qui affiche une liste de photos dans une grille :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour mettre à jour l'état des posts après un like :
- Tri vertical
Ordonne les étapes de l'effet d'overlay au survol d'une photo dans la galerie :
- Tri horizontal
Ordonne les classes sur une seule ligne pour une carte photo carrée dans la vignette de la galerie :
- Tri vertical
Ordonne l'imbrication des éléments de la carte photo dans la galerie (de l'extérieur vers l'intérieur) :
- Tri horizontal
Ordonne les classes de galerie responsive sur une seule ligne (grid + mobile → tablette → ordinateur) :
- Tri vertical
Ordonne la logique du bouton 'Publier' du commentaire, inactif quand le champ est vide :