Vibe coding with AI course ยท Module 6: YouTube Clone
Comments and Viewer Engagement
In this lesson3
A video without comments is a port without a tavern: it works, but nobody stays long. Viewers want to comment on a video, reply to others, like it and find out that someone answered them. We'll build three pieces: comments with replies, like and subscribe buttons, and a notification bell.
Comments and replies
A comment has an author, content, a like count and a date. The most interesting part, though, is the replies field:
interface Comment {
id: number;
author: string;
content: string;
likes: number;
replies: Comment[];
createdAt: string;
}
function CommentsList({ videoId }: { videoId: number }) {
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch(`/api/videos/${videoId}/comments`, {
method: 'POST',
body: JSON.stringify({ content: newComment }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
setComments([data.comment, ...comments]);
setNewComment("");
};
return (
<div>
<form onSubmit={handleSubmit} className="mb-6">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="w-full border rounded p-3"
/>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded mt-2"
>
Comment
</button>
</form>
<div className="space-y-4">
{comments.map(comment => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
</div>
);
}
function CommentCard({ comment }: { comment: Comment }) {
return (
<div className="flex gap-3">
<div className="w-10 h-10 bg-gray-300 rounded-full" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-bold">{comment.author}</span>
<span className="text-sm text-gray-500">{comment.createdAt}</span>
</div>
<p className="text-gray-800 mb-2">{comment.content}</p>
<div className="flex gap-4 text-sm">
<button className="hover:text-blue-600">
{comment.likes}
</button>
<button className="hover:text-blue-600">Reply</button>
</div>
</div>
</div>
);
}replies: Comment[] is an array of comments inside a comment, that is a recursive type: every reply can have replies of its own. handleSubmit works in four steps. First it sends the content with POST to /api/videos/${videoId}/comments, then it reads the response with res.json(), puts the new comment at the top of the list with setComments([data.comment, ...comments]), and finally clears the field with setNewComment(""). The older comments stay in place, they just move down.
For now CommentCard shows a single level. You render the replies by adding, under the buttons, a fragment that calls the same component for each of them:
{comment.replies.map(reply => (
<div key={reply.id} className="ml-12 mt-3">
<CommentCard comment={reply} />
</div>
))}A component that renders itself can handle a thread of any depth, and the ml-12 indent shows who is replying to whom.
Likes and subscriptions
EngagementButtons toggles the like and the channel subscription:
function EngagementButtons({ videoId }: { videoId: number }) {
const [liked, setLiked] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const [likes, setLikes] = useState(0);
const handleLike = async () => {
await fetch(`/api/videos/${videoId}/like`, { method: 'POST' });
setLiked(!liked);
setLikes(liked ? likes - 1 : likes + 1);
};
const handleSubscribe = async () => {
await fetch(`/api/channels/subscribe`, { method: 'POST' });
setSubscribed(!subscribed);
};
return (
<div className="flex gap-4">
<button
onClick={handleLike}
className={`px-4 py-2 rounded ${liked ? 'bg-blue-600 text-white' : 'bg-gray-200'}`}
>
{likes}
</button>
<button
onClick={handleSubscribe}
className={`px-6 py-2 rounded ${subscribed ? 'bg-gray-800 text-white' : 'bg-red-600 text-white'}`}
>
{subscribed ? 'Subscribed' : 'Subscribe'}
</button>
</div>
);
}Look at the line setLikes(liked ? likes - 1 : likes + 1). Calling setLiked(!liked) doesn't change the liked variable during this click, so the condition still sees the state from before the click. If the video was liked, we subtract one, and if not, we add one. Both buttons wait for the server's response, though. Many apps use an optimistic update here: they change the counter first and roll it back if an error occurs. Notice also that handleSubscribe doesn't tell the server which channel you are subscribing to. In a real app, add the channel id to the address or to the request body.
Notifications every 30 seconds
The bell should show new notifications without reloading the page. The simplest way is to ask the server at regular intervals:
function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
// Poll for new notifications
const interval = setInterval(async () => {
const res = await fetch('/api/notifications');
const data = await res.json();
setNotifications(data.notifications);
}, 30000); // Every 30s
return () => clearInterval(interval);
}, []);
return (
<div className="relative">
<button onClick={() => setOpen(!open)} className="relative">
Notifications
{notifications.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{notifications.length}
</span>
)}
</button>
{open && (
<div className="absolute top-full right-0 w-80 bg-white border rounded shadow-lg mt-2 max-h-96 overflow-auto">
{notifications.map(notif => (
<div key={notif.id} className="p-3 border-b hover:bg-gray-50">
<p className="text-sm">{notif.message}</p>
<span className="text-xs text-gray-500">{notif.createdAt}</span>
</div>
))}
</div>
)}
</div>
);
}setInterval(..., 30000) fetches new notifications from the API every 30 seconds and stores them in state, and the number on the red badge is notifications.length. The function returned from useEffect calls clearInterval when the component disappears, so the polling doesn't keep running in the background. The first fetch happens only after 30 seconds. Polling like this is simple, but slow to react and wasteful. In the next module you'll meet WebSockets, which deliver messages right away.
Watch the type: Notification isn't declared here, and the browser has a built-in interface with that name, used for system notifications. Declare your own, e.g. AppNotification with the fields id, message and createdAt.
Captain Redbeard's tip: display comment content as plain text in JSX. React then turns markup into safe text, so nobody can smuggle a script aboard your ship. Avoid dangerouslySetInnerHTML for content written by users.
Remember: a comment can contain comments, the like counter is computed from the state before the click, and polling means asking the server at a steady pace. In the editor below you have comments with replies, likes counted from the state before the click, a subscribe button and polling every 30 seconds, and the tasks will check replies, likes and polling.
Code for this lesson: App.jsx
// Comments and engagement: replies, likes, subscription and polling
import { useState, useEffect } from 'react';
const initialComments = [
{
id: 1,
author: 'Kapitan Redbeard',
text: 'Great video about treasure maps!',
likes: 12,
replies: [{ id: 2, author: 'Anne Bonny', text: 'Agreed, waiting for part two.', likes: 3, replies: [] }],
},
{ id: 3, author: 'Jack Silverhand', text: 'Where can I buy a compass like that?', likes: 1, replies: [] },
];
function CommentCard({ comment, onReply, onLike, depth }) {
const [replying, setReplying] = useState(false);
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
onReply(comment.id, text.trim());
setText('');
setReplying(false);
};
return (
<div className={depth > 0 ? 'ml-6 border-l border-gray-700 pl-4' : ''}>
<div className="bg-gray-800 rounded-lg p-3">
<p className="text-sm font-semibold">{comment.author}</p>
<p className="text-sm text-gray-300">{comment.text}</p>
<div className="flex gap-3 mt-2 text-xs text-gray-400">
<button onClick={() => onLike(comment.id)} className="hover:text-white">
Like ({comment.likes})
</button>
{depth === 0 && (
<button onClick={() => setReplying(!replying)} className="hover:text-white">
Reply
</button>
)}
</div>
{replying && (
<form onSubmit={handleSubmit} className="flex gap-2 mt-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Your reply"
className="flex-1 bg-gray-900 rounded px-2 py-1 text-sm"
/>
<button className="text-sm px-2 rounded bg-red-600">Send</button>
</form>
)}
</div>
<div className="mt-2 space-y-2">
{comment.replies.map((r) => (
<CommentCard key={r.id} comment={r} onReply={onReply} onLike={onLike} depth={depth + 1} />
))}
</div>
</div>
);
}
export default function App() {
const [comments, setComments] = useState(initialComments);
const [text, setText] = useState('');
const [subscribed, setSubscribed] = useState(false);
const [subscribers, setSubscribers] = useState(1280);
const [lastCheck, setLastCheck] = useState(new Date());
// Polling: every 30 seconds we ask the server for new comments (here we only store the time).
useEffect(() => {
const interval = setInterval(() => setLastCheck(new Date()), 30000);
return () => clearInterval(interval);
}, []);
const addComment = (e) => {
e.preventDefault();
if (!text.trim()) return;
setComments((prev) => [{ id: Date.now(), author: 'You', text: text.trim(), likes: 0, replies: [] }, ...prev]);
setText('');
};
const handleReply = (parentId, replyText) => {
setComments((prev) =>
prev.map((c) =>
c.id === parentId
? { ...c, replies: [...c.replies, { id: Date.now(), author: 'You', text: replyText, likes: 0, replies: [] }] }
: c
)
);
};
// The counter grows from the state before the click: prev is the current list, even with fast clicks.
const like = (list, id) =>
list.map((c) => (c.id === id ? { ...c, likes: c.likes + 1 } : { ...c, replies: like(c.replies, id) }));
const handleLike = (id) => setComments((prev) => like(prev, id));
const handleSubscribe = () => {
setSubscribers((n) => (subscribed ? n - 1 : n + 1));
setSubscribed((s) => !s);
};
return (
<div className="min-h-screen bg-gray-900 text-white p-6">
<div className="max-w-xl mx-auto space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-bold">Treasure map, part 1</h1>
<p className="text-sm text-gray-400">
{subscribers.toLocaleString('en-US')} subscribers
</p>
</div>
<button
onClick={handleSubscribe}
className={`px-4 py-2 rounded-full font-semibold ${subscribed ? 'bg-gray-700' : 'bg-red-600 hover:bg-red-500'}`}
>
{subscribed ? 'Subscribed' : 'Subscribe'}
</button>
</div>
<form onSubmit={addComment} className="flex gap-2">
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a comment"
className="flex-1 bg-gray-800 rounded px-3 py-2 text-sm"
/>
<button className="px-3 rounded bg-red-600 text-sm font-semibold">Add</button>
</form>
<div className="space-y-3">
{comments.map((c) => (
<CommentCard key={c.id} comment={c} onReply={handleReply} onLike={handleLike} depth={0} />
))}
</div>
<p className="text-xs text-gray-500">
Last check for new comments: {lastCheck.toLocaleTimeString('en-US')}
</p>
</div>
</div>
);
}
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Which field of the Comment interface stores nested replies?
2. What does setInterval(..., 30000) do in the NotificationBell component?
Hands-on tasks in the game
- Vertical ordering
Arrange the steps of adding a new comment in handleSubmit (top to bottom):
- Horizontal ordering
Arrange the expression that updates the likes count after a click (single line):