HumanOnlyWeb

🍁 writer of code. drinker of coffee. human on the web.

Building a Commenting System [Part 4]: Optimistic UI and the useComments Composable

By HumanOnlyWeb

When you click a reaction button, you don't want to wait 200ms for the server to confirm it worked. The UI should respond immediately. But what if the server fails?

What Is Optimistic UI?

Traditional flow:

  1. User clicks button
  2. Show loading state
  3. Wait for server response
  4. Update UI

Optimistic flow:

  1. User clicks button
  2. Update UI immediately (assume success)
  3. Send request to server
  4. If it fails, roll back the UI

The second approach feels instant, but the tradeoff is complexity: you need to track what to roll back if things go wrong.

For a commenting system, I use optimistic updates for:

  • Reactions: Toggle immediately, sync with server response
  • Creating comments: Add to tree immediately (though I wait for the server to get the real ID)
  • Updating/deleting: Show changes immediately

Let's look at how this works in the useComments composable.

The Composable Structure

Here's the skeleton:

export function useComments(postSlug: MaybeRefOrGetter<string>) {
  const slug = computed(() => toValue(postSlug));

  // State
  const comments = useState<CommentResponse[]>(`comments-${slug.value}`, () => []);
  const totalCount = useState<number>(`comments-count-${slug.value}`, () => 0);
  const sortOrder = useState<"newest" | "oldest">(`comments-sort-${slug.value}`, () => "newest");
  const isLoading = useState<boolean>(`comments-loading-${slug.value}`, () => false);
  // ... more state

  // Methods
  async function fetchComments() { ... }
  async function createComment(params) { ... }
  async function updateComment(params) { ... }
  async function deleteComment(id) { ... }
  async function toggleReaction(commentId) { ... }

  // Tree helpers
  function findInTree(items, id) { ... }
  function addToTree(comment) { ... }
  function updateInTreeById(id, updater) { ... }

  return { comments, totalCount, sortOrder, /* ... */ };
}

I use useState instead of ref because Nuxt's useState survives hydration. If the page is server-rendered, the state transfers to the client without re-fetching. Learn more in Nuxt's state management docs.

The key pattern is prefixing state keys with the post slug (comments-${slug.value}). This means each blog post has its own isolated comment state. Navigate between posts and each one keeps its data.

Tree Operations

Comments are nested, so I need recursive helpers to find and update items anywhere in the tree.

Finding a comment:

function findInTree(items: CommentResponse[], id: string): CommentResponse | null {
  for (const item of items) {
    if (item.id === id) return item;
    if (item.replies) {
      const found = findInTree(item.replies, id);
      if (found) return found;
    }
  }
  return null;
}

Updating a comment:

function updateInTreeById(id: string, updater: (comment: CommentResponse) => void) {
  const updateRecursive = (items: CommentResponse[]): boolean => {
    for (const item of items) {
      if (item.id === id) {
        updater(item);
        return true;
      }
      if (item.replies && updateRecursive(item.replies)) {
        return true;
      }
    }
    return false;
  };
  updateRecursive(comments.value);
}

The updater callback pattern is flexible. Instead of passing specific fields to update, I pass a function that mutates the comment however needed.

Adding a comment to the tree:

function addToTree(comment: CommentResponse) {
  if (comment.parentId) {
    // It's a reply, find the parent and add to its replies
    const addReply = (items: CommentResponse[]): boolean => {
      for (const item of items) {
        if (item.id === comment.parentId) {
          item.replies = item.replies || [];
          item.replies.push(comment);
          return true;
        }
        if (item.replies && addReply(item.replies)) {
          return true;
        }
      }
      return false;
    };
    addReply(comments.value);
  } else {
    // It's a top-level comment
    if (sortOrder.value === "newest") {
      comments.value.unshift(comment);
    } else {
      comments.value.push(comment);
    }
  }
}

Replies get added to their parent's replies array. Top-level comments go at the beginning or end depending on sort order.

Optimistic Reactions with Rollback

Here's the full toggleReaction implementation:

async function toggleReaction(commentId: string) {
  const updateInTree = (
    items: CommentResponse[],
    updater: (comment: CommentResponse) => void,
  ): boolean => {
    for (const comment of items) {
      if (comment.id === commentId) {
        updater(comment);
        return true;
      }
      if (comment.replies && updateInTree(comment.replies, updater)) {
        return true;
      }
    }
    return false;
  };

  // 1. Store previous state for rollback
  let previousHasReacted = false;
  let previousReactionCount = 0;

  updateInTree(comments.value, (comment) => {
    previousHasReacted = comment.hasReacted;
    previousReactionCount = comment.reactionCount;
  });

  // 2. Optimistically update UI
  updateInTree(comments.value, (comment) => {
    comment.hasReacted = !comment.hasReacted;
    comment.reactionCount += comment.hasReacted ? 1 : -1;
  });

  try {
    // 3. Send request to server
    const result = await $fetch<ReactionToggleResponse>(
      `/api/comments/${commentId}/reactions`,
      { method: "POST" }
    );

    // 4. Sync with server response (handles race conditions)
    updateInTree(comments.value, (comment) => {
      comment.hasReacted = result.added;
      comment.reactionCount = result.count;
    });

    return result;
  } catch (e) {
    // 5. Roll back on failure
    updateInTree(comments.value, (comment) => {
      comment.hasReacted = previousHasReacted;
      comment.reactionCount = previousReactionCount;
    });
    throw e;
  }
}

Step by step:

  1. Store previous state: Before changing anything, capture the current values. These are our "save point."
  2. Optimistic update: Immediately flip hasReacted and adjust the count. The UI updates instantly.
  3. Server request: Send the actual toggle request.
  4. Sync with server: Even on success, I use the server's response to set the final state. Why? Race conditions. If someone else reacted while my request was in flight, the server's count is the source of truth.
  5. Rollback on failure: If the request fails (network error, server error, etc.), restore the previous state. The UI snaps back to what it was.

Why Sync Even on Success?

You might wonder why step 4 exists. If my optimistic update was correct, why override it?

Imagine this scenario:

  1. The comment has 5 reactions
  2. I click to add mine (optimistic: 6 reactions, hasReacted: true)
  3. While my request is in flight, someone else also reacts
  4. Server processes their reaction first (now 6 in DB)
  5. Server processes mine (now 7 in DB)
  6. Server responds to me: { added: true, count: 7 }

If I didn't sync with the server, I'd show 6 reactions when there are actually 7. The server response is always the most accurate.

Handling SSE Deduplication

In , I covered how SSE broadcasts events to all connected clients. But when I create a comment, I get two signals:

  1. The HTTP response from my POST request
  2. The SSE event broadcast to all clients (including me)

Without handling this, I'd add the comment twice. Why? These signals can arrive in either order. The SSE event might arrive before the POST response completes, or vice versa. I need to handle both cases.

In the SSE handler:

watch(sseData, (raw) => {
  // ... parse event ...

  switch (event.type) {
    case "comment:created": {
      // Don't add if already present (handles own comments)
      if (!findInTree(comments.value, event.comment.id)) {
        addToTree({ ...event.comment, isOwner: false });
        totalCount.value++;
      }
      break;
    }
    // ... other cases
  }
});

In createComment:

async function createComment(params: { content: string; parentId?: string }) {
  const newComment = await $fetch<CommentResponse>(`/api/posts/${slug.value}/comments`, {
    method: "POST",
    body: params,
  });

  // Skip adding if SSE already added this comment while we were waiting
  if (findInTree(comments.value, newComment.id)) {
    return newComment;
  }

  addToTree(newComment);
  totalCount.value++;
  return newComment;
}

Both directions check findInTree before adding. If SSE arrives first, the POST handler skips adding. If POST arrives first, the SSE handler skips adding. Either way, the comment appears exactly once.

I also mark SSE comments with isOwner: false. When I create a comment via HTTP, the response has isOwner: true. This ensures the "Edit" and "Delete" buttons show correctly regardless of how the comment was added to the tree.

Sort Order and Re-fetching

When the user changes the sort order, I re-fetch from the server:

const sortOrder = useState<"newest" | "oldest">(`comments-sort-${slug}`, () => "newest");

watch(sortOrder, () => {
  void fetchComments();
});

I could sort client-side, but that only works if I have all comments loaded. With pagination, the server needs to return comments in the right order. Re-fetching is simpler and guarantees correctness.

Computed Helpers

A few computed properties make the template cleaner:

const commentsEnabled = computed(() => postSettings.value?.commentsEnabled !== false);
const reactionsEnabled = computed(() => postSettings.value?.reactionsEnabled !== false);
const maxNestingDepth = computed(() => postSettings.value?.maxNestingDepth ?? 3);

The !== false pattern is intentional. If postSettings is null (not loaded yet), I default to enabled. This prevents a flash of "comments disabled" while the settings load.

The Full Return

Here's everything the composable exposes:

return {
  // State
  comments,
  postSettings,
  totalCount,
  nextCursor,
  isLoading,
  error,
  sortOrder,
  sseConnected,

  // Computed
  commentsEnabled,
  reactionsEnabled,
  maxNestingDepth,

  // Methods
  fetchComments,
  createComment,
  updateComment,
  deleteComment,
  toggleReaction,
};

Components can use whatever they need. The form component uses createComment. The reaction button uses toggleReaction. The header shows totalCount and sseConnected status.

Testing Optimistic Updates

If you want to see optimistic updates in action:

  1. Open DevTools → Network tab
  2. Enable "Slow 3G" throttling
  3. Click a reaction button

You'll see the count change immediately, then the network request happening in the background. If you're on a fast connection, it all happens so fast you don't notice. But on slow networks, the instant feedback makes a huge difference.

What's Next

We've covered the database, real-time updates, and state management. In the next and final part, I'll wrap up the series with moderation: soft deletes, hiding comments, handling reports, and the admin panel that ties it all together.

All Comments 0

Be the first to comment.