Build a suggestion list
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
- Add comments integration for a full review workflow
- Explore the utilities API for more query functions like
getSuggestionAtSelectionandfindSuggestionsInRange