> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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](https://www.sanity.io/docs/collaboration/get-started).
- 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:

```tsx
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:

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

What a click does is up to you. See [Open a thread from a badge](https://www.sanity.io/docs/collaboration/show-comment-counts).

`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.

> [!WARNING]
> Never put a suspending hook in a table cell. One `useCommentThreads` per row is one subscription and one Suspense boundary per row, and the first render becomes a waterfall. Read once, group, and pass numbers down.

## 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:

```tsx
// `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](https://www.sanity.io/docs/collaboration/sdk-query-comments).

`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:

```tsx
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.

```tsx
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](https://www.sanity.io/docs/collaboration/show-comment-counts):

```tsx
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](https://www.sanity.io/docs/collaboration/show-a-comment-preview).

## Next steps

- A comment in a popover, opened from the badge: [Show a comment preview](https://www.sanity.io/docs/collaboration/show-a-comment-preview).
- Which read to reach for, and why counts have none: [Comment data flow](https://www.sanity.io/docs/collaboration/data-flow).
- Every `CommentBadge` prop and state: [CommentBadge](https://collaboration-platform.sanity.dev/?path=/docs/default-commentbadge--docs).
- A count on each row and a badge per field, as an example you can run: [document-list](https://github.com/sanity-io/collaboration-platform-examples/tree/main/examples/document-list).



## Related changelog entries

Entries are listed newest first; each link points to the full entry as markdown. Follow one when you need to know what changed, when, or why — for example, to summarize recent updates, explain behavior that differs from older documentation, or check whether a fix has shipped.

- [@sanity/collaboration v0.3.0: New send shortcut, Cancel that clears the draft, and a discard action for failed comments](https://www.sanity.io/docs/changelog/e3fbc3a3-2d35-40e6-9309-786ec3e24b49.md) — October 29, 2026