The post detail page is a natural candidate for real-time updates: when multiple users are viewing the same post and someone adds a comment, everyone on the page should see it appear immediately. This is the room-based WebSocket pattern from Chapter 31 — each post’s detail page subscribes to the room "post:{id}", and the comment creation HTTP endpoint broadcasts the new comment to all subscribers of that room. React updates the comment list locally without waiting for RTK Query cache invalidation.
Live Comment Feed
// src/hooks/useLiveComments.js
import { useState, useCallback } from "react";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useAuthStore } from "@/stores/authStore";
export function useLiveComments(postId, initialComments = []) {
const isLoggedIn = useAuthStore((s) => Boolean(s.user));
const [comments, setComments] = useState(initialComments);
const [viewerCount, setViewerCount] = useState(0);
const handleMessage = useCallback((data) => {
switch (data.type) {
case "new_comment":
// Prepend new comment — most recent first
setComments((prev) => {
// Avoid duplicates (in case HTTP response also added it)
const exists = prev.some((c) => c.id === data.comment.id);
return exists ? prev : [data.comment, ...prev];
});
break;
case "room_joined":
case "viewer_joined":
case "viewer_left":
setViewerCount(data.viewers ?? 0);
break;
case "comment_deleted":
setComments((prev) => prev.filter((c) => c.id !== data.comment_id));
break;
}
}, []);
const { status, send } = useWebSocket(
postId ? `/ws/posts/${postId}/live` : null,
{
onMessage: handleMessage,
enabled: isLoggedIn && Boolean(postId),
}
);
return { comments, setComments, viewerCount, connectionStatus: status };
}
prev.some(c => c.id === data.comment.id)) is important because two paths can deliver the same comment to the UI: the HTTP response from the POST /comments call (if this user submitted it), and the WebSocket broadcast (which reaches all viewers including the author). Without the duplicate check, the submitting user sees their comment appear twice — once from the HTTP response and once from the WebSocket broadcast.initialComments from the RTK Query response so the comment list is pre-populated from the server on page load. The WebSocket then layers real-time additions on top. This hybrid approach (HTTP for initial load, WebSocket for updates) is more robust than pure WebSocket delivery — if the WebSocket is unavailable (corporate proxy, network issue), the page still shows comments from the HTTP cache.setComments function is included in the hook’s return value so the parent component can also update the comments array when the user submits a new comment via the HTTP POST (for an optimistic update before the WebSocket broadcast arrives). If the parent only relies on the WebSocket broadcast to add the new comment, there is a noticeable delay between clicking “Submit” and seeing the comment appear — the HTTP response is much faster than waiting for the WebSocket round-trip.PostDetailPage Integration
import { useGetPostByIdQuery, useCreateCommentMutation } from "@/store/apiSlice";
import { useLiveComments } from "@/hooks/useLiveComments";
import { useAuthStore } from "@/stores/authStore";
function PostDetailPage() {
const { postId } = useParams();
const { data: post, isLoading } = useGetPostByIdQuery(Number(postId));
const user = useAuthStore((s) => s.user);
const { comments, setComments, viewerCount } = useLiveComments(
Number(postId),
post?.comments ?? [] // initialise from RTK Query response
);
const [createComment, { isLoading: isSubmitting }] = useCreateCommentMutation();
const [body, setBody] = useState("");
const toast = useToast();
async function handleSubmit(e) {
e.preventDefault();
if (!body.trim()) return;
// Optimistic: add comment locally before server confirms
const optimisticComment = {
id: `temp-${Date.now()}`, // temp ID
body,
author: user,
created_at: new Date().toISOString(),
};
setComments((prev) => [optimisticComment, ...prev]);
setBody("");
try {
const saved = await createComment({ postId: Number(postId), body }).unwrap();
// Replace optimistic entry with real server data
setComments((prev) =>
prev.map((c) => c.id === optimisticComment.id ? saved : c)
);
// WebSocket broadcast will arrive and be deduped by saved.id
} catch {
// Rollback optimistic update
setComments((prev) => prev.filter((c) => c.id !== optimisticComment.id));
toast.error("Failed to post comment");
}
}
if (isLoading) return <PostDetailSkeleton />;
return (
<article>
<h1>{post.title}</h1>
{viewerCount > 1 && (
<p className="text-sm text-gray-400">
{viewerCount} people viewing this post
</p>
)}
<CommentList comments={comments} />
{user && (
<form onSubmit={handleSubmit}>
<textarea value={body} onChange={e => setBody(e.target.value)}
placeholder="Write a comment…" rows={3}
className="w-full border rounded-lg p-3" />
<button type="submit" disabled={isSubmitting || !body.trim()}>
{isSubmitting ? "Posting…" : "Post Comment"}
</button>
</form>
)}
</article>
);
}
Common Mistakes
Mistake 1 — Not deduplicating WebSocket messages (double comments)
❌ Wrong — submitting user sees comment twice (HTTP + WebSocket).
✅ Correct — check for existing ID before prepending from WebSocket message.
Mistake 2 — No optimistic update (visible delay after submit)
❌ Wrong — waiting for WebSocket broadcast before showing the new comment.
✅ Correct — add optimistic entry immediately, replace with server data when it arrives.