Comment data flow
How comment data reaches the components as props and leaves through callbacks, and why the wiring lives in your own code.
Comment data does not live in the components. Every one of them takes the data it renders as a prop and reports what the user did through a callback, so the reads, the writes, and the wiring between them are a page of your own code. This page explains what that page looks like, and why it is shaped the way it is.
The components come with one adapter that writes the wiring for you, and knowing what it does is the point of reading on.
Where the data comes from
Three hooks from @sanity/sdk-react/collaboration cover it:
useCommentThreadsreturns every thread on one document, already assembled into parent, replies, count, and status. This is the read a panel is built on, and its result goes straight intoCommentList.useCommentsQueryreturns comments across documents, filtered with GROQ. This is the escape hatch from "comments on this document".useCommentActionsreturns every write as one object.
The SDK guides cover each hook task by task, and link to the full signatures. What matters here is the shape of what they hand back, and what that implies for the components.
Reads suspend, and errors are yours
Read comments covers this from the SDK side. useCommentThreads suspends on first load, so the Suspense boundary belongs to you:
<Suspense fallback={<p>Loading comments…</p>}>
<Panel />
</Suspense>Later changes to the read's options do not suspend again. They run as a transition and report isPending, which is what to dim the list with:
const {threads, isPending} = useCommentThreads({...handle, status})
return <Stack style={{opacity: isPending ? 0.6 : undefined}}>{/* … */}</Stack>An error boundary is yours too, but only for the first load. Nothing in the components catches on your behalf.
Once a list has loaded it stops throwing. A listener that drops afterwards leaves the comments already on screen alone and reports through error, which clears when a listener comes back. What you are reading then is the list as it last stood rather than as it is, which is worth saying rather than replacing the list with a failure:
const {threads, error} = useCommentThreads(handle)
return (
<Stack>
{error && <Text muted>Showing the comments as they last arrived.</Text>}
{threads.map(/* … */)}
</Stack>
)The reads key on the handle's fields, so a fresh handle object on each render keeps the same subscription. useSDKThreadCallbacks is the exception: its callbacks follow the handle's identity, so memoize the handle you pass it.
Writes are optimistic
Write comments covers every action from the SDK side. Every action applies immediately, and every action except a create or reply rolls back if it is rejected. The re-render arrives through the same useCommentThreads subscription you are already reading, which is why a callback is one line and returns nothing.
This diagram could not be rendered.
sequenceDiagram
participant C as CommentThread
participant H as Your callback
participant A as useCommentActions
participant R as useCommentThreads
C->>H: onReply(message)
H->>A: replyToComment(...)
A-->>R: applied optimistically
R-->>C: re-render, with the new comment
A->>A: the request settles
A-->>R: rejected, so kept with a createError state
R-->>C: re-render, with the reply marked as failed
Three consequences follow.
Edits, deletes, status, and reactions resolve to void. Creates and replies resolve to the new comment. Either way the re-render arrives through the read. To scroll to a comment you created, pass your own comment ID to createComment and find it in the next render.
A failed create stays on screen. It carries a local state of createError, which renders the comment in the critical tone with an error notice under the message. Pass onRetry and the notice offers a retry. The comment's only action is a discard button that calls onDelete without a confirmation, since nothing reached the server.
Observing a write means .then or .catch on the action's promise. Nothing about the rendered result depends on your doing so.
A failed edit is the gap in this: the local state covers a failed create only, so a rejected edit reverts the message with nothing on screen to say why. See Show a failed write for how to say it yourself.
One callback, one action
Every write a CommentThread offers maps onto exactly one action:
Card callback | Action |
|---|---|
|
|
|
|
|
|
|
|
|
|
| The original write again, under the same comment ID |
| None. Only your app knows what its URLs look like |
The callbacks are comment-first where a thread holds more than one comment, and the thread's own writes take no comment. Every action in the table needs only a comment ID, except retrying a failed comment that started a thread. That retry goes through createComment, the one write that needs a document handle. So a cross-document surface that wants to start threads has to know each row's document.
The adapter that writes the mapping for you
useSDKThreadCallbacks wires every card callback to its action except onCopyLink, which only your app can wire:
function Panel() {
const {threads} = useCommentThreads({...handle, status: 'open'})
const threadCallbacks = useSDKThreadCallbacks(handle)
return (
<CommentList threads={threads}>
{(thread) => (
<CommentThread
key={thread.threadId}
thread={thread}
{...threadCallbacks(thread)}
/>
)}
</CommentList>
)
}It is an adapter, not a wrapper. The card still takes callbacks and still works against data that came from somewhere else, which is why this is a hook rather than something the card does itself. Spread it before your own props and yours win, so a surface can override one write and take the rest.
There are no Document* wrapper components that would hide the join, and no data hooks in this package: every read is your app's, panel and editor alike. An editor does get an SDK-wired component, SDKPortableTextComments, because the join between a selection and a comment's anchor is intricate enough to be worth one, but it takes the threads you read and only the writes are its own. See Inline comments.
One comment the adapter cannot re-send is a thread parent anchored to a run of text. Creating that comment needs the anchor it was written against, and a comment only carries the resolved selection, so the adapter reports it to onError instead. SDKPortableTextComments has no retry of its own either: the host passes its own onRetry that calls createComment with the anchor.
Show a failed write
Only a failed create or reply leaves a trace in the data, as a createError state with a retry. An edit, delete, resolve, or reaction that is rejected rolls back with nothing on screen. useSDKThreadCallbacks takes an onError for those, called with the error and the name of the card callback that asked for the write, such as 'onEdit'. This panel shows every failure as a toast:
import type {DocumentHandle} from '@sanity/sdk'
import {useSDKThreadCallbacks} from '@sanity/collaboration/sdk'
import {CommentList, CommentThread} from '@sanity/collaboration/ui'
import {useCommentThreads} from '@sanity/sdk-react/collaboration'
import {useToast} from '@sanity/ui/toast'
export function Panel({handle}: {handle: DocumentHandle}) {
const {threads} = useCommentThreads({...handle, status: 'open'})
const toast = useToast()
const threadCallbacks = useSDKThreadCallbacks(handle, {
onError: (error) =>
toast.push({
status: 'error',
title: 'Your change to the comment was not saved',
description: error instanceof Error ? error.message : undefined,
}),
})
return (
<CommentList threads={threads}>
{(thread) => (
<CommentThread
key={thread.threadId}
thread={thread}
{...threadCallbacks(thread)}
/>
)}
</CommentList>
)
}SDKCommentsPanel takes the same onError, which also covers a new thread that did not land. useToast needs a ToastProvider from @sanity/ui/toast around the panel. Leave onError out and every failure goes to console.error. Nothing the adapter does throws, so this is the only place a failed write surfaces.
Which read for which surface
One document is useCommentThreads. It returns threads, so the surface renders CommentThread, and every action is available.
Across documents is useCommentsQuery (Query comments): a mentions inbox, a moderation queue, an activity feed. It returns comments rather than threads, which is why those surfaces render Comment rather than CommentThread. Every write except starting a thread takes a comment ID, so a row can still offer editing, deleting, resolving, and reactions. Replying is the one a row leaves out, because the reply composer belongs to the thread card rather than to a single comment.
Counts have no read of their own. Read the document's threads once, group by field, and sum. See Show comment counts.
Reactions are two actions
The components model a reaction as a toggle, and the SDK has an explicit pair, so the callback carries the state before the toggle. active: true means the current user already reacted, so the write is a removal:
onReactionToggle={(comment, shortName, active) =>
active
? actions.removeReaction({commentId: comment.id, shortName})
: actions.addReaction({commentId: comment.id, shortName})
}Passing no onReactionToggle renders reactions read-only.
Where to go next
- The wiring end to end: Get started.
- The hooks this package adds for the surface itself, rather than the data: Hooks.
- What sits under these hooks, and what you give up by reaching for it: Levels of abstraction in the comment stack.
- What no read answers yet, and where those answers will land: Limitations.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?