docs/solutions/developer-experience/2026-04-19-slate-public-single-op-writes-should-use-editor-apply-and-keep-onchange-behind-subscribers.md
Slate v2 cannot claim an authoritative editor.update / commit runtime while
editor.apply and editor.onChange are still viable extension points.
The correct shape is:
editor.applyOperations(...) for explicit operation import/replayeditor.extend({ operationMiddlewares }) for low-level operation interceptionEditor.subscribe(...) and Editor.registerCommitListener(...) for
observationeditor.apply as machinery, not plugin APIeditor.apply = ... still worked in contracts and encouraged
monkeypatch plugins.editor.onChange = ... still appeared in tests as a reentry/observation
hook.slate-dom wrapped apply to keep DOM node maps synchronized around
operations.editor.selection / editor.marks after those public mirrors were cut.editor.children / editor.selection
directly instead of snapshot accessors.Editor.apply(editor, op) as the only explicit public single-op seam.
It still left instance editor.apply looking like the thing plugin authors
should replace.editor.onChange as "after subscribers" compatibility. The callback
still looked like commit authority and kept reentry tests pointed at the wrong
abstraction.selection or marks mirrors to make React code
compile. That would reintroduce the stale-state habit the architecture is
cutting.Seal the legacy extension points and name the real ones.
editor.applyOperations([
{
type: 'insert_text',
path: [0, 0],
offset: 5,
text: '!',
},
])
Use operation middleware when a runtime package needs to observe or wrap low-level operations:
editor.extend({
name: 'operation-spy',
operationMiddlewares: [
({ operation }, next) => {
// pre-operation bookkeeping
next(operation)
// post-operation bookkeeping
},
],
})
Use commit subscribers for observation:
const unsubscribe = Editor.subscribe(editor, (_snapshot, commit) => {
if (!commit) return
// history, React, and runtime observers consume commit metadata here
})
Implementation details that matter:
BaseEditor does not expose onChange.createEditor() defines instance apply as non-writable and
non-configurable.applyOperations(...) runs through the transaction/update pipeline.slate-dom operation interception composes through extension middleware.<Slate onChange> remains a component prop, but it is backed by
snapshot/commit subscription instead of editor.onChange.applyOperations(...).getSelection() and
getMarks().The design separates three jobs that legacy apply/onChange blurred:
That keeps plugins powerful without letting them replace the core operation applier or observe stale callback timing.
It also aligns with the broader Slate v2 architecture:
editor.update owns local writesEditorCommit owns runtime observationeditor.apply as plugin API.editor.onChange back to BaseEditor.editor.applyOperations(...).editor.getSelection() and editor.getMarks().editor.onChange is absenteditor.apply cannot be redefinedapplyOperations(...) publishes commits