Collaboration (Beta)

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.
  • @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.

Get the actions

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

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:

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.

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.

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.
  • anchor, to attach the comment to a run of text. See 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 []:

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:

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:

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:

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:

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

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme