Collaboration (Beta)

Show comment counts

Show a comment count next to a field or row with CommentBadge, read the counts efficiently, and open a thread from the badge.

CommentBadge renders an icon and a number next to a field, a row, or anything else that has comments on it. This guide covers where that number comes from, because the obvious approach does not scale, and how to open a thread from the badge.

Prerequisites:

  • A working comments panel, or at least a CommentsProvider around the badges. See Get started with comments.
  • An app with @sanity/sdk-react 3.11.0 or later, the first version that ships the @sanity/sdk-react/collaboration entry point.

Count comments on one document

There is no count-only read. Call useCommentThreads once for the document, group by fieldPath, and sum commentsCount. Read both statuses, so that a field whose threads are all resolved can say so, and count the open ones. This first block also carries the imports for every snippet on the page:

import type {CommentThreadData} from '@sanity/collaboration'
import {
  CommentBadge,
  CommentInput,
  CommentList,
  type CommentListHandle,
} from '@sanity/collaboration/ui'
import {
  useCommentActions,
  useCommentsQuery,
  useCommentThreads,
} from '@sanity/sdk-react/collaboration'
import {useMemo, useRef, useState} from 'react'

function sumByField(threads: CommentThreadData[]) {
  const counts = new Map<string, number>()

  for (const thread of threads) {
    counts.set(
      thread.fieldPath,
      (counts.get(thread.fieldPath) ?? 0) + thread.commentsCount,
    )
  }

  return counts
}

// Inside a component that takes `documentId`:
const handle = {documentId, documentType: 'post'}
const {threads} = useCommentThreads(handle)

const openThreads = useMemo(
  () => threads.filter((thread) => thread.status === 'open'),
  [threads],
)
const countsByField = useMemo(() => sumByField(openThreads), [openThreads])
const resolvedByField = useMemo(
  () => sumByField(threads.filter((thread) => thread.status === 'resolved')),
  [threads],
)

Then a badge per field is a lookup:

<CommentBadge count={countsByField.get('title') ?? 0} />

What a click does is up to you. See Open a thread from a badge.

commentsCount is the parent plus its replies, so summing it per field gives a count of comments. To count threads instead, sum 1 per thread.

Count comments across documents

For a table of documents, one read per row is the wrong shape, because each row adds its own subscription and Suspense boundary. Use useCommentsQuery with a GROQ filter and group the result yourself.

The filter runs over every comment the organization can read, not only the ones in your dataset, so add a prefix condition on target.document._ref to keep to one dataset. A reference looks like dataset:{projectId}.{dataset}:{documentId}, so build the prefix from the project ID and dataset your app is configured with. Then count by documentId, which is always the published ID:

// `projectId` and `dataset` are the values your app is configured with.
const {comments} = useCommentsQuery({
  filter:
    'string::startsWith(target.document._ref, $prefix) && status == "open"',
  params: {prefix: `dataset:${projectId}.${dataset}:`},
})

const countsByDocument = useMemo(() => {
  const counts = new Map<string, number>()

  for (const comment of comments) {
    counts.set(comment.documentId, (counts.get(comment.documentId) ?? 0) + 1)
  }

  return counts
}, [comments])

A row's badge is then countsByDocument.get(row.documentId) ?? 0. See Scope to one dataset.

useCommentsQuery returns comments rather than threads, which is what you want here: a count is a count of comments, and no thread assembly is needed to produce one.

The status == "open" filter counts replies as well as parents. That count stays right when a thread is resolved: setCommentStatus on the parent also sets the status of its replies on the server, in the same transaction.

Render the empty, unread, and resolved states

By default a badge with no comments renders nothing. For a field that should offer commenting anyway, emptyBehavior="add" renders the add-comment affordance instead. A field with no threads has no group in the list to scroll to, so have the badge open a composer for that field:

const [composing, setComposing] = useState<string>()
const {createComment} = useCommentActions()

<CommentBadge
  count={0}
  emptyBehavior="add"
  onClick={() => setComposing('title')}
/>

{composing && (
  <CommentInput
    placeholder={`Comment on ${composing}…`}
    onDiscard={() => setComposing(undefined)}
    onSubmit={async (message) => {
      await createComment({...handle, fieldPath: composing, message})
      setComposing(undefined)
    }}
  />
)}

state covers the other two: "unread" is the accented badge, and "resolved" the dimmed one for a field whose threads are all resolved. It is named state rather than tone because it decides the tone rather than being one, and Sanity UI's own tone is not settable here. A resolved badge dims to the same opacity as a resolved comment, so the dimmed states read alike.

const open = countsByField.get('title') ?? 0
const resolved = resolvedByField.get('title') ?? 0

<CommentBadge
  count={open || resolved}
  state={open === 0 && resolved > 0 ? 'resolved' : 'default'}
/>

Read state is yours to track. Neither the SDK nor the package records which comments a user has seen, so "unread" only renders when your app passes it.

max caps the rendered number and defaults to 9, so a count of 12 renders "9+". The default accessible name still gives the real count: "12 comments".

Open a thread from a badge

A badge renders a button, so what happens on click is your own state. Two shapes are common.

A panel that scrolls. Keep a CommentListHandle and call scrollToField. This works for a field with open threads. For an empty field, open a composer as in Render the empty, unread, and resolved states:

const list = useRef<CommentListHandle>(null)

<CommentBadge
  count={countsByField.get('title') ?? 0}
  onClick={() => list.current?.scrollToField('title')}
/>
<CommentList ref={list} threads={openThreads}>{/* … */}</CommentList>

scrollToField only resolves while CommentList groups by field, which is the default (groupBy="field").

A popover on the badge. One CommentThread, or a read-only Comment for a preview. See Show a comment preview.

Next steps

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme