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

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

- **useCommentThreads** returns 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 into `CommentList`.
- **useCommentsQuery** returns comments across documents, filtered with GROQ. This is the escape hatch from "comments on this document".
- **useCommentActions** returns every write as one object.

The [SDK guides](https://www.sanity.io/docs/collaboration/sdk-overview) 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](https://www.sanity.io/docs/collaboration/sdk-read-comments) covers this from the SDK side. `useCommentThreads` suspends on first load, so the Suspense boundary belongs to you:

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

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

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

```mermaid
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](https://www.sanity.io/docs/collaboration/data-flow) for how to say it yourself.

## One callback, one action

Every write a `CommentThread` offers maps onto exactly one action:

| Card callback | Action |
| --- | --- |
| `onReply(message)` | `replyToComment`, on the thread's first comment |
| `onEdit(comment, message)` | `updateComment` |
| `onDelete(comment)` | `removeComment` |
| `onStatusChange(status)` | `setCommentStatus`, on the thread's first comment |
| `onReactionToggle(comment, shortName, active)` | `addReaction` or `removeReaction` |
| `onRetry(comment)` | The original write again, under the same comment ID |
| `onCopyLink(comment)` | 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:

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

**Panel.tsx**

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

```tsx
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](https://www.sanity.io/docs/collaboration/get-started).
- The hooks this package adds for the surface itself, rather than the data: [Hooks](https://collaboration-platform.sanity.dev/?path=/docs/hooks--docs).
- What sits under these hooks, and what you give up by reaching for it: [Levels of abstraction in the comment stack](https://www.sanity.io/docs/collaboration/levels-of-abstraction).
- What no read answers yet, and where those answers will land: [Limitations](https://www.sanity.io/docs/collaboration/introduction).

