Build a suggestion list

Paid add-on

Learn how to build a sidebar component that lists all pending suggestions in the document, showing who made each change and allowing users to accept or reject them.

Framework note

This guide uses React, but the same concepts apply to Vue and any other framework supported by Tiptap.

Prerequisites

This guide assumes you have a working editor with the Tracked Changes extension. If you don't, follow the editor setup guide first.

Query suggestions

The extension exports a findSuggestions utility that returns all suggestions in the document. Call it on onCreate and onUpdate to keep your list in sync.

import { useState } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { TrackedChanges, findSuggestions } from '@tiptap-pro/extension-tracked-changes'

function Editor() {
  const [suggestions, setSuggestions] = useState([])

  const editor = useEditor({
    extensions: [
      StarterKit,
      TrackedChanges.configure({
        enabled: true,
        userId: 'user-123',
        userMetadata: { name: 'John Doe' },
      }),
    ],
    content: '<p>Start editing to see tracked changes.</p>',
    onCreate: ({ editor: currentEditor }) => {
      setSuggestions(findSuggestions(currentEditor))
    },
    onUpdate: ({ editor: currentEditor }) => {
      setSuggestions(findSuggestions(currentEditor))
    },
  })

  // Deduplicate — a suggestion can span multiple text nodes
  const uniqueSuggestions = Array.from(
    new Map(suggestions.map(s => [s.id, s])).values(),
  )

  if (!editor) {
    return null
  }

  return (
    <div className="editor-layout">
      <div className="editor-content">
        <EditorContent editor={editor} />
      </div>
      <SuggestionList
        suggestions={uniqueSuggestions}
        onAccept={(id) => {
          editor.commands.acceptSuggestion({ id })
          setSuggestions(findSuggestions(editor))
        }}
        onReject={(id) => {
          editor.commands.rejectSuggestion({ id })
          setSuggestions(findSuggestions(editor))
        }}
      />
    </div>
  )
}

★ Insight ───────────────────────────────────── A single suggestion can span multiple text nodes when it crosses formatting boundaries (e.g. part of the text is bold). findSuggestions returns one entry per text node, so deduplication by id is essential to avoid showing the same suggestion multiple times in the list. ─────────────────────────────────────────────────

Build the SuggestionList component

Each suggestion has a type (add, delete, or replace), the changed text, user metadata, and a timestamp. Use these to render a clear list.

function SuggestionList({ suggestions, onAccept, onReject }) {
  if (suggestions.length === 0) {
    return (
      <div className="suggestion-list">
        <p>No pending suggestions</p>
      </div>
    )
  }

  return (
    <div className="suggestion-list">
      <h3>Suggestions ({suggestions.length})</h3>
      {suggestions.map(suggestion => (
        <SuggestionItem
          key={suggestion.id}
          suggestion={suggestion}
          onAccept={() => onAccept(suggestion.id)}
          onReject={() => onReject(suggestion.id)}
        />
      ))}
    </div>
  )
}

Build the SuggestionItem component

Display the suggestion type, text preview, user info, and action buttons.

When a suggestion affects a block-level node (such as a paragraph or heading), the suggestion exposes insertedNodes or deletedNodes — arrays of JSONContent describing the full node structure. Use these to render a node type badge above the content text.

function SuggestionItem({ suggestion, onAccept, onReject }) {
  const typeLabel = {
    add: '+ Insertion',
    delete: '− Deletion',
    replace: '~ Replacement',
  }

  // Derive a node type label from block-level node suggestions
  const nodes = suggestion.insertedNodes ?? suggestion.deletedNodes
  const nodeType = nodes?.[0]?.type

  return (
    <div className="suggestion-item">
      <div className="suggestion-item__type">
        {typeLabel[suggestion.type]}
      </div>

      {nodeType && (
        <span className="suggestion-item__node-badge">
          {nodeType}
        </span>
      )}

      <div className="suggestion-item__text">
        "{suggestion.text}"
        {suggestion.type === 'replace' && suggestion.replacedText && (
          <span className="suggestion-item__replaced-text">
            {' '}(was: "{suggestion.replacedText}")
          </span>
        )}
      </div>

      <div className="suggestion-item__meta">
        {suggestion.userMetadata?.name || suggestion.userId}
        {' · '}
        {new Date(suggestion.createdAt).toLocaleTimeString()}
      </div>

      <div className="suggestion-item__actions">
        <button onClick={onAccept}>Accept</button>
        <button onClick={onReject}>Reject</button>
      </div>
    </div>
  )
}

Style the components to match your UI. Here is a minimal CSS starting point:

.editor-layout {
  display: flex;
  gap: 1rem;
}

.editor-content {
  flex: 1;
}

.suggestion-list {
  width: 280px;
  padding: 1rem;
}

.suggestion-item {
  padding: 0.75rem;
  margin-bottom: 0.5rem;
  border-radius: 0.375rem;
  border: 1px solid #e2e8f0;
}

.suggestion-item__type {
  font-size: 0.75rem;
  font-weight: 600;
  margin-bottom: 0.25rem;
}

.suggestion-item__node-badge {
  display: inline-block;
  font-size: 0.65rem;
  font-weight: 600;
  padding: 0.1rem 0.4rem;
  border-radius: 0.25rem;
  background: #f1f5f9;
  color: #475569;
  margin-bottom: 0.25rem;
}

.suggestion-item__text {
  font-size: 0.875rem;
  margin-bottom: 0.25rem;
}

.suggestion-item__replaced-text {
  color: #64748b;
}

.suggestion-item__meta {
  font-size: 0.75rem;
  color: #64748b;
  margin-bottom: 0.5rem;
}

.suggestion-item__actions {
  display: flex;
  gap: 0.5rem;
}

Add bulk actions

Add buttons above the list to accept or reject all suggestions at once.

function SuggestionList({ suggestions, onAccept, onReject, onAcceptAll, onRejectAll }) {
  return (
    <div className="suggestion-list">
      <h3>Suggestions ({suggestions.length})</h3>

      {suggestions.length > 0 && (
        <div className="suggestion-list__bulk-actions">
          <button onClick={onAcceptAll}>Accept all</button>
          <button onClick={onRejectAll}>Reject all</button>
        </div>
      )}

      {suggestions.map(suggestion => (
        <SuggestionItem
          key={suggestion.id}
          suggestion={suggestion}
          onAccept={() => onAccept(suggestion.id)}
          onReject={() => onReject(suggestion.id)}
        />
      ))}
    </div>
  )
}

Wire up the bulk actions in the parent:

<SuggestionList
  suggestions={uniqueSuggestions}
  onAccept={(id) => {
    editor.commands.acceptSuggestion({ id })
    setSuggestions(findSuggestions(editor))
  }}
  onReject={(id) => {
    editor.commands.rejectSuggestion({ id })
    setSuggestions(findSuggestions(editor))
  }}
  onAcceptAll={() => {
    editor.commands.acceptAllSuggestions()
    setSuggestions([])
  }}
  onRejectAll={() => {
    editor.commands.rejectAllSuggestions()
    setSuggestions([])
  }}
/>

Filter suggestions

Use the options parameter on findSuggestions to filter by type or user:

import { findSuggestions } from '@tiptap-pro/extension-tracked-changes'

// Only insertions
const insertions = findSuggestions(editor, 'suggestion', { type: 'add' })

// Only deletions
const deletions = findSuggestions(editor, 'suggestion', { type: 'delete' })

// Only from a specific user
const userSuggestions = findSuggestions(editor, 'suggestion', { userId: 'user-123' })

You can add filter controls in your sidebar to let users view specific suggestion types.

Listen to events

For more reactive updates, listen to the extension's events instead of (or in addition to) polling on onUpdate:

import { useEffect } from 'react'

useEffect(() => {
  if (!editor) return

  const updateSuggestions = () => {
    setSuggestions(findSuggestions(editor))
  }

  editor.on('trackedChanges:suggestionCreated', updateSuggestions)
  editor.on('trackedChanges:suggestionAccepted', updateSuggestions)
  editor.on('trackedChanges:suggestionRejected', updateSuggestions)
  editor.on('trackedChanges:suggestionRemoved', updateSuggestions)
  editor.on('trackedChanges:suggestionsUpdated', updateSuggestions)

  return () => {
    editor.off('trackedChanges:suggestionCreated', updateSuggestions)
    editor.off('trackedChanges:suggestionAccepted', updateSuggestions)
    editor.off('trackedChanges:suggestionRejected', updateSuggestions)
    editor.off('trackedChanges:suggestionRemoved', updateSuggestions)
    editor.off('trackedChanges:suggestionsUpdated', updateSuggestions)
  }
}, [editor])

Next steps