docs/solutions/logic-errors/2026-04-15-annotation-store-inputs-must-keep-stable-data-references.md
The new review-comments example loaded the editor route, then blew up with
Maximum update depth exceeded.
The failure looked like a generic React loop at first, but the real cause was
more specific: the example rebuilt fresh annotation payload objects on every
render and fed them straight into useSlateAnnotationStore(...).
Memoize the annotation entries so unchanged comments keep the same data
reference identity across renders.
Bad:
const annotationStore = useSlateAnnotationStore(
editor,
comments.map((comment) => ({
id: comment.id,
bookmark: comment.bookmark,
data: {
body: comment.body,
label: comment.label,
tone: comment.tone,
},
}))
);
Good:
const annotations = useMemo(
() =>
comments.map((comment) => ({
id: comment.id,
bookmark: comment.bookmark,
data: comment,
})),
[comments]
);
const annotationStore = useSlateAnnotationStore(editor, annotations);
createSlateAnnotationStore(...) treats annotation snapshots as unchanged only
when the bookmark, resolved range, and data object keep stable identity.
If you create a fresh data object every render, the hook refreshes the store
every render, which can cascade into mounted editor rerenders and eventually a
loop.
When feeding useSlateAnnotationStore(...):
data references stable for unchanged itemsThe same bias applies to widget and projection input arrays too. If the store contract compares by reference, treat input identity like part of the API.
The same rule bit /examples/search-highlighting: the search input updated
decorations correctly, but if the editor had focus first, typing the first
letter moved focus back to the editor.
The cause was rebuilding createSlateProjectionStore(...) from React search
state. Changing the input changed state, state recreated the projection store,
and the editor remount path restored the previous editor focus.
Bad:
const [search, setSearch] = useState('')
const projectionStore = useMemo(
() =>
createSlateProjectionStore(
editor,
(snapshot) => collectSearchProjections(snapshot.children, search),
{ dirtiness: ['text', 'external'], sourceId: 'search-highlighting' }
),
[editor, search]
)
Good:
const searchRef = useRef('')
const projectionStore = useMemo(
() =>
createSlateProjectionStore(
editor,
(snapshot) =>
collectSearchProjections(snapshot.children, searchRef.current),
{ dirtiness: ['text', 'external'], sourceId: 'search-highlighting' }
),
[editor]
)
const handleSearchChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
searchRef.current = event.currentTarget.value
projectionStore.refresh({ reason: 'external' })
},
[projectionStore]
)
Keep the store stable. Put external control state in a ref, then explicitly refresh the store with the external dirtiness reason.