Cours Vibe coding avec l'IA · Module 4: Clone d'Instagram

State avancé - Système de commentaires et de likes

8 min de lecture
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. 1. Comment mets-tu à jour les likes d'un post spécifique dans un tableau ?

  2. 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 :

Articles utiles