Skip to Content
PackagesReactComments UI

Comments (React)

The comments UI. @stesura/comments-react has the components and commentsUiExtension; the CommentAdapter type, CommentProvider and hooks come from @stesura/editor-react (/types and /providers), with CommentProvider and useCommentCtx re-exported by @stesura/comments-react.

For setup, the document model and commands see Comments.

CommentAdapter

Your storage backend, passed to <CommentProvider value={adapter}>. Import the type from @stesura/editor-react/types. Only useThreads and createThread are required; each optional method enables the matching UI (no resolveThread, no Resolve button). None is called in view mode.

MemberSignatureDescription
useThreads(query: { resolved: boolean; editorId: string }) => { threads: Thread[]; isLoading: boolean }Required. React hook returning one editor’s threads.
createThread(params: { body: JSONContent; metadata: Record<string, unknown> }) => Promise<Thread>Required. Creates a thread with its first comment. metadata carries { editorId }.
addComment(params: { threadId; body }) => voidAdds a reply.
editComment(params: { threadId; commentId; body }) => Promise<void>Edits a comment.
deleteComment(params: { threadId; commentId }) => voidSoft-deletes a reply (sets deletedAt).
undoDeleteComment(params: { threadId; commentId }) => voidClears deletedAt.
deleteThread(threadId: string) => Promise<void>Soft-deletes a thread (sets deletedAt). Also called for orphaned threads.
undoDeleteThread(threadId: string) => Promise<void>Clears deletedAt. Also called for restored threads.
resolveThread(threadId: string) => Promise<void>Marks a thread resolved.
unresolveThread(threadId: string) => Promise<void>Reopens it.
addReaction(params: { threadId; commentId; emoji }) => voidAdds the current user’s reaction.
removeReaction(params: { threadId; commentId; emoji }) => voidRemoves it.
getThreadsForExport(editorId: string, state: EditorState) => Promise<CommentsDocx[]>Non-hook fetch used by DOCX export.
currentUserIdstringThe current user: only their own comments can be edited or deleted, and their reactions show as active. Without it nothing can be edited or deleted.

Contract

  • useThreads is called twice per editor, with resolved: false and resolved: true, and the results are merged. Return only threads whose resolved matches exactly and that belong to editorId (store the metadata.editorId you get in createThread). Returning everything from both calls shows every thread twice.
  • Keep returning soft-deleted threads with deletedAt set. The UI hides them, but needs them to undo a deletion and to restore a thread when its anchors come back.
  • Report isLoading honestly. Orphan cleanup waits until both calls have loaded: a partial list makes live threads look unknown, and their anchors would be removed.
  • Reject on failure. The UI waits for the promise-returning methods: a failed edit keeps the draft in the composer, and success toasts (with Undo) only show once the call resolves.
  • Normalize before modifying. An adapter that reads, modifies and writes back a stored thread must run normalizeSerializedThread (from @stesura/comments) on what it reads. The reaction helpers assume one group per emoji and one entry per user.

Authorization is the adapter’s job

The UI only offers edit and delete to a comment’s author (currentUserId), and deleting a thread’s first comment deletes the whole thread. These are affordances, not security: re-check who is asking on the server, for every mutation. Moderator rules, for example, belong there.

Adapter data is untrusted

In a shared document, thread bodies, author names, avatars and mention IDs are written by other users:

  • Mention IDs reach resolveUsers({ userIds }) verbatim. Encode them before putting them in a URL, query or filter.
  • Avatar URLs are sanitized (javascript: and non-raster data: URLs fall back to initials), but any web URL is still fetched by every viewer. Filter them in the adapter if that matters.
  • Comment bodies are validated against the comment schema; a body that doesn’t parse is shown as unavailable, never rendered as markup.

Hooks

useCommentCtx()

Returns the adapter, or null without a provider. For calling adapter methods directly.

import { useCommentCtx } from "@stesura/editor-react/providers"; const adapter = useCommentCtx();

useComments(options?)

import { useComments } from "@stesura/editor-react/providers"; const { threads, isLoading } = useComments({ resolved: false }); const { threads: intro } = useComments({ resolved: true, editorId: "intro" });

Calls the adapter’s useThreads. Safe without a provider. threads: undefined means no adapter or not loaded yet, not zero threads.

OptionTypeDefaultDescription
resolvedbooleanfalseWhich set to read (exact match).
editorIdstringthe current editorWhich editor’s threads to read.

UI components

Exported from @stesura/comments-react:

ExportPurpose
commentsUiExtensionRegisters everything below plus the toolbar groups (Insert, Review), the text-menu button and the context-menu items.
ThreadsContainerThe comments root: the rail or floating panel, the composer, screen-reader announcements, and the sync between adapter, stores and document (visible threads, orphan cleanup). Renders nothing without an adapter.
AnchoredThreadsThe rail: thread cards next to the page, aligned with their anchors.
FloatingThreadsThe selected thread in a popover under its anchor.
FloatingComposerThe new-comment composer, shown next to the pending range or node.

The display mode is the showComments user preference, set from the Review tab: anchored (rail), minimized (rail of bubbles), floating or none. The rail modes aren’t available in the compact layout.

Render the components yourself only in a custom editor shell. ThreadsContainer must stay mounted: without it no thread is marked visible and nothing is highlighted.

Author names, avatars and mention suggestions come from resolveUsers and resolveMentionSuggestions on StesuraUserProvider, see Getting started.

Implementing your own adapter

An in-memory adapter:

import type { Thread } from "@stesura/core/types"; import type { CommentAdapter } from "@stesura/editor-react/types"; import { useMemo, useState } from "react"; function useMemoryCommentAdapter(currentUserId: string): CommentAdapter { const [threads, setThreads] = useState<Thread[]>([]); return useMemo(() => { const update = (id: string, change: Partial<Thread>) => setThreads((all) => all.map((t) => (t.id === id ? { ...t, ...change } : t))); return { currentUserId, useThreads: ({ resolved, editorId }) => { const matching = useMemo( () => threads.filter((t) => t.resolved === resolved && t.metadata.editorId === editorId), [threads, resolved, editorId] ); return { threads: matching, isLoading: false }; }, createThread: async ({ body, metadata }) => { const id = crypto.randomUUID(); const now = new Date(); const thread: Thread = { id, resolved: false, createdAt: now, metadata, comments: [ { id: crypto.randomUUID(), threadId: id, createdAt: now, body, author: { id: currentUserId } }, ], }; setThreads((all) => [...all, thread]); return thread; }, deleteThread: async (id) => update(id, { deletedAt: new Date() }), undoDeleteThread: async (id) => update(id, { deletedAt: undefined }), resolveThread: async (id) => update(id, { resolved: true }), unresolveThread: async (id) => update(id, { resolved: false }), }; }, [threads, currentUserId]); }

For collaboration, use createCollabCommentAdapter from @stesura/collab-adapter-react instead, see Collaboration.

Next steps

Last updated on