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
CommentsProvideraround the badges. See Get started with comments. - An app with
@sanity/sdk-react3.11.0 or later, the first version that ships the@sanity/sdk-react/collaborationentry 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.
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:
// `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
- A comment in a popover, opened from the badge: Show a comment preview.
- Which read to reach for, and why counts have none: Comment data flow.
- Every
CommentBadgeprop and state: CommentBadge. - A count on each row and a badge per field, as an example you can run: document-list.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?