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 style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<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 style={{ width: 280, padding: '1rem' }}>
<p>No pending suggestions</p>
</div>
)
}
return (
<div style={{ width: 280, padding: '1rem' }}>
<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.
function SuggestionItem({ suggestion, onAccept, onReject }) {
const typeLabel = {
add: '+ Insertion',
delete: '− Deletion',
replace: '~ Replacement',
}
return (
<div style={{
padding: '0.75rem',
marginBottom: '0.5rem',
borderRadius: '0.375rem',
border: '1px solid #e2e8f0',
}}>
<div style={{ fontSize: '0.75rem', fontWeight: 600, marginBottom: '0.25rem' }}>
{typeLabel[suggestion.type]}
</div>
<div style={{ fontSize: '0.875rem', marginBottom: '0.25rem' }}>
"{suggestion.text}"
{suggestion.type === 'replace' && suggestion.replacedText && (
<span style={{ color: '#64748b' }}>
{' '}(was: "{suggestion.replacedText}")
</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '0.5rem' }}>
{suggestion.userMetadata?.name || suggestion.userId}
{' · '}
{new Date(suggestion.createdAt).toLocaleTimeString()}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={onAccept}>Accept</button>
<button onClick={onReject}>Reject</button>
</div>
</div>
)
}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 style={{ width: 280, padding: '1rem' }}>
<h3>Suggestions ({suggestions.length})</h3>
{suggestions.length > 0 && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<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