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

# Write comments

Start threads, reply, edit, delete, resolve, and react with useCommentActions, and handle a failed write.

`useCommentActions` returns every comment write as one object. This guide starts a thread, replies, edits, deletes, resolves, and reacts, and covers what happens when a write fails.

Prerequisites:

- An app with the SDK set up and a signed-in user. See [Setup](https://www.sanity.io/docs/collaboration/sdk-overview).
- `@sanity/sdk-react` 3.11.0 or later, which adds the `@sanity/sdk-react/collaboration` export.
- A read on screen to see the writes land. See [Read comments](https://www.sanity.io/docs/collaboration/sdk-read-comments).

## Get the actions

`useCommentActions` takes no arguments and returns the write actions. Destructure the ones a component needs:

```tsx
import {useCommentActions} from '@sanity/sdk-react/collaboration'

function ResolveButton({commentId}: {commentId: string}) {
  const {setCommentStatus} = useCommentActions()

  return (
    <button
      onClick={() =>
        void setCommentStatus({commentId, status: 'resolved'}).catch(
          (error: unknown) => {
            // A rejected status change rolls back silently, so say so.
            window.alert(`Could not resolve the comment: ${String(error)}`)
          },
        )
      }
    >
      Resolve
    </button>
  )
}
```

Every action returns a promise. `createComment` and `replyToComment` resolve to the written `Comment`; the rest resolve to nothing. In a click handler, mark the promise as handled with `void`, or catch it, so it is not left floating. A rejected status change rolls back silently, so catch it if the user should hear about it.

## Write a message

A message is Portable Text, `PortableTextBlock[]`. There is no composer helper, so wrap plain text yourself:

```typescript
import type {CommentMessage} from '@sanity/sdk-react/collaboration'

function toMessage(text: string): CommentMessage {
  return [
    {
      _type: 'block',
      _key: crypto.randomUUID(),
      style: 'normal',
      markDefs: [],
      children: [{_type: 'span', _key: crypto.randomUUID(), text, marks: []}],
    },
  ]
}
```

Mentions are inline objects of type `mention` carrying a `userId`. The SDK stores and returns them untouched, but it has no API for composing one. The `CommentInput` component from `@sanity/collaboration/ui` composes them; see [Enable mentions](https://www.sanity.io/docs/collaboration/enable-mentions).

## Start a thread

`createComment` takes a document handle, optionally the field the thread belongs to, and the message. The handle's resource and perspective decide where it is written; see [Resources and perspectives](https://www.sanity.io/docs/collaboration/sdk-overview).

```tsx
const {createComment} = useCommentActions()

await createComment({
  documentId: 'post-123',
  documentType: 'post',
  fieldPath: 'title',
  message: toMessage('Can we make this shorter?'),
})
```

It also takes:

- **commentId and threadId** to write against IDs you chose. Each defaults to a new ID, and a new `threadId` starts a new thread.
- **documentRevisionId**, the `_rev` of the document the comment was written about. It is not filled in for you.
- **context**, free-form data of your own, handed back on the comment's `context` wherever you read it.
- No `fieldPath`, to comment on the document as a whole. See [Comment on the whole document](https://www.sanity.io/docs/collaboration/sdk-write-comments).
- **anchor**, to attach the comment to a run of text. See [Anchor inline comments](https://www.sanity.io/docs/collaboration/sdk-anchor-inline-comments).

## Comment on the whole document

A comment can be about the document as a whole rather than one field: a note on its direction, a review sign-off. Leave `fieldPath` out of `createComment`, or pass `''` or `[]`:

```tsx
await createComment({
  documentId: 'post-123',
  documentType: 'post',
  message: toMessage('Approved for publishing.'),
})
```

The comment is written without a path and reads back with `fieldPath: ''`, so `useCommentThreads({documentId: 'post-123', documentType: 'post', fieldPath: ''})` reads only these. Replies land on the document too, since a reply takes its placement from its parent. An `anchor` still needs a field: a create with an anchor and no field path rejects with `A comment with an anchor needs a field path to anchor it in.`

In Sanity Studio with Comments API v2, the comments inspector lists field comments only, so document-level comments don't show there. They're still stored, and every SDK read returns them.

## Reply, edit, and delete

`replyToComment` takes the comment being replied to as `parentCommentId`. `updateComment` and `removeComment` take the `commentId` of the comment they change:

```tsx
const {replyToComment, updateComment, removeComment} = useCommentActions()

await replyToComment({parentCommentId, message: toMessage('Done.')})
await updateComment({commentId, message: toMessage('Done, and shortened.')})
await removeComment({commentId})
```

- **replyToComment** reads the thread, the field, and the document off the parent, which has to be loaded: replying to a comment no read holds rejects. A reply to a reply joins the same thread. It takes `commentId` and `context` as `createComment` does.
- **updateComment** rewrites the message and stamps `lastEditedAt`.
- **removeComment** deletes the comment, and its replies too when it starts a thread.

## Resolve and reopen

`setCommentStatus` sets a thread's status to `'resolved'` or `'open'`. Pass the thread's first comment; its replies follow it:

```tsx
await setCommentStatus({commentId: thread.parentComment.id, status: 'resolved'})
```

## Add and remove reactions

Reactions are two explicit actions rather than a toggle, since a toggle would guess wrong whenever the list is a moment stale:

```tsx
const {addReaction, removeReaction} = useCommentActions()

await addReaction({commentId, shortName: ':+1:'})
await removeReaction({commentId, shortName: ':+1:'})
```

Both act on the current user's reaction. Adding one that is already there is harmless. `shortName` is one of a closed set, typed as `CommentReactionShortName`: `':-1:'`, `':+1:'`, `':eyes:'`, `':heart:'`, `':heavy_plus_sign:'`, and `':rocket:'`. The API rejects anything else.

## Optimistic writes

Every action writes optimistically: the change shows immediately and rolls back if the server rejects it. Render straight from `useCommentThreads` without tracking pending state.

Creates and replies reach every list they belong in, whichever one you wrote them from, so a document's comments and a query that matches them stay in step.

Rolling back is silent. A rejected edit, delete, status change, or reaction reverts on screen and rejects its promise, and nothing else records it, so catch the promise if the user should hear about it.

## Retry a failed create

Creating is the exception to rolling back, and `replyToComment` counts as creating. A comment that fails to post stays on screen carrying `state.type === 'createError'` rather than disappearing, so nobody loses what they typed.

Passing the same `commentId` again retries it:

```tsx
import {type Comment, useCommentActions} from '@sanity/sdk-react/collaboration'

function Retry({comment}: {comment: Comment}) {
  const {createComment, replyToComment} = useCommentActions()
  if (comment.state?.type !== 'createError') return null

  const retry = () =>
    comment.parentCommentId
      ? replyToComment({
          commentId: comment.id,
          parentCommentId: comment.parentCommentId,
          message: comment.message,
          context: comment.context,
        })
      : createComment({
          commentId: comment.id,
          threadId: comment.threadId,
          documentId: comment.sourceDocumentId,
          documentType: comment.documentType,
          fieldPath: comment.fieldPath,
          message: comment.message,
          context: comment.context,
        })

  return <button onClick={() => void retry()}>Retry</button>
}
```

Pass `sourceDocumentId` rather than `documentId`, which is always the published ID: a retry should land on the draft or version the comment was first written against. `context` is passed again so the retry keeps it.

A retry re-issues `createComment` or `replyToComment` with the same comment ID. If the first request reached the server, the API can reject the retry as a conflict.

While a retry is in flight the comment carries `state.type === 'createRetrying'` instead, so a button keyed on `createError` steps aside on its own. Key a "Sending…" style on `createRetrying`.

An inline comment is the one this cannot retry: `createComment` wants the `anchor` it was written with, and a comment carries only the resolved `selection`. Keep the anchor where you wrote it, and pass it again.

## Next steps

- [Reference](https://www.sanity.io/docs/collaboration/sdk-overview), for every option each action takes.
- [Comment data flow](https://www.sanity.io/docs/collaboration/data-flow), for showing a failed write in the components.

