Plate editors expose grouped reads, atomic updates, runtime services, and commit subscriptions. Import createEditor from platejs for headless work or from platejs/react for a mounted editor.
The Editor reference covers typed plugin access and editor configuration. This page describes document operations shared by those editors.
createEditor(options?) => EditorCreate an editor.
import { createEditor } from "platejs";
const editor = createEditor({
initialValue: [{ type: "paragraph", children: [{ text: "Body" }] }],
maxLength: 1000,
});import { createEditor } from "platejs";
const editor = createEditor({
initialValue: [{ type: "paragraph", children: [{ text: "Body" }] }],
maxLength: 1000,
});Plugins define schema, corrections, commit listeners, owner-local read and update groups, and optional runtime APIs.
maxLength limits user-facing text, fragment, and node insertions. Adapter-owned
canonical change application remains outside user insertion policy.
Run one committed-state read.
const selection = editor.read.selection();
const isExpanded = editor.read.selection.isExpanded();
const spansBlocks = editor.read.selection.isAcrossBlocks();
const startsBlock = editor.read.selection.isAtBlockStart();
const containsTitle = editor.read.selection.contains([0]);
const endsWord = selection
? editor.read.points.isWordEnd(selection.anchor)
: false;
const text = editor.read.text.string([]);
const isInline = editor.read.schema.isInline(element);const selection = editor.read.selection();
const isExpanded = editor.read.selection.isExpanded();
const spansBlocks = editor.read.selection.isAcrossBlocks();
const startsBlock = editor.read.selection.isAtBlockStart();
const containsTitle = editor.read.selection.contains([0]);
const endsWord = selection
? editor.read.points.isWordEnd(selection.anchor)
: false;
const text = editor.read.text.string([]);
const isInline = editor.read.schema.isInline(element);Selection predicates accept explicit targets when a command should inspect a location other than the current selection.
const startsHeading = editor.read.selection.isAtBlockStart({
at: point,
type: "heading",
});const startsHeading = editor.read.selection.isAtBlockStart({
at: point,
type: "heading",
});Read a coherent snapshot of editor state when several reads should share the same state view.
const selection = editor.read((state) => state.selection());const selection = editor.read((state) => state.selection());Use state for editor-state queries:
editor.read((state) => {
const children = state.nodes.children();
const marks = state.marks();
const first = state.nodes.get([0]);
const isCollapsed = state.selection.isCollapsed();
const start = state.points.start([]);
const range = state.ranges.get([]);
return { children, first, isCollapsed, marks, range, start };
});editor.read((state) => {
const children = state.nodes.children();
const marks = state.marks();
const first = state.nodes.get([0]);
const isCollapsed = state.selection.isCollapsed();
const start = state.points.start([]);
const range = state.ranges.get([]);
return { children, first, isCollapsed, marks, range, start };
});Schema policy is available through direct read methods for common one-shot
checks and through state.schema for grouped reads:
const direct = editor.read.schema.isInline(element);
const grouped = editor.read((state) => state.schema.isInline(element));const direct = editor.read.schema.isInline(element);
const grouped = editor.read((state) => state.schema.isInline(element));Public read and update methods with an at option accept a NodeTarget: a
Path, Point, Range, or live descendant.
type NodeTarget<N extends Descendant = Descendant> = Location | N;type NodeTarget<N extends Descendant = Descendant> = Location | N;Resolve a node when the path itself matters:
const path = editor.read.nodes.path(element);const path = editor.read.nodes.path(element);The read returns undefined for an unresolved node. Resolution is scoped to
the editor root, so nodes from another editor or another root do not resolve.
Strict static helpers keep their non-optional contracts and treat a missing
result as an internal invariant failure.
Node queries use type for structural selection and match for an optional
predicate.
const callout = editor.read.nodes.find({ type: "callout" });
const tableNode = editor.read.nodes.find({
type: ["table", "table_cell"],
});const callout = editor.read.nodes.find({ type: "callout" });
const tableNode = editor.read.nodes.find({
type: ["table", "table_cell"],
});An array selects any listed type. Add match: (node, path) => ... for computed
conditions and static type narrowing.
Run one transaction write with the default policy.
editor.update.marks.toggle("bold");
editor.update.text.insert("Title");
editor.update.selection.move({ distance: 1 });
editor.update.nodes.set({ icon: "🔥" }, { at: calloutElement });editor.update.marks.toggle("bold");
editor.update.text.insert("Title");
editor.update.selection.move({ distance: 1 });
editor.update.nodes.set({ icon: "🔥" }, { at: calloutElement });Configure one direct write.
editor.update({ history: "skip" }).text.insert("Imported");
editor.update({ tags: ["paste", "html"] }).slice.replace(importedSlice);editor.update({ history: "skip" }).text.insert("Imported");
editor.update({ tags: ["paste", "html"] }).slice.replace(importedSlice);The configured facade exposes core and installed plugin update methods. A
method marked with txOnly(...) is omitted because it requires an explicit
transaction. editor.update(policy) returns that configured facade; a direct
method returns the underlying method result.
Run one atomic update with the default policy. The callback receives tx, which
owns reads and writes for the active transaction.
editor.update((tx) => {
tx.nodes.set({ type: "heading" });
tx.text.insert("Title");
tx.selection.move({ distance: 1 });
});editor.update((tx) => {
tx.nodes.set({ type: "heading" });
tx.text.insert("Title");
tx.selection.move({ distance: 1 });
});Run one atomic update with a semantic policy.
editor.update({ history: "new-batch", tags: "paste" }, (tx) => {
tx.slice.replace(importedSlice);
tx.selection.collapse({ edge: "end" });
});editor.update({ history: "new-batch", tags: "paste" }, (tx) => {
tx.slice.replace(importedSlice);
tx.selection.collapse({ edge: "end" });
});type EditorUpdatePolicy = Readonly<{
history?: "merge" | "new-batch" | "skip";
tags?: EditorUpdateTag | readonly EditorUpdateTag[];
}>;type EditorUpdatePolicy = Readonly<{
history?: "merge" | "new-batch" | "skip";
tags?: EditorUpdateTag | readonly EditorUpdateTag[];
}>;Plate installs History by default. Tags are applied in input order before history; only the last history mode remains.
Inside the callback, tx.tags.add(tag) updates the final tag set and
tx.tags.has(tag) inspects it. History adds tx.history.skip(),
tx.history.merge(), and tx.history.newBatch() as transaction-only controls
for decisions made after the update starts.
The update callback also receives a context object for local post-commit hooks:
editor.update((tx, { afterCommit }) => {
tx.text.insert("Saved");
afterCommit((change) => {
analytics.track("editor-change", { tags: change.tags });
});
});editor.update((tx, { afterCommit }) => {
tx.text.insert("Saved");
afterCommit((change) => {
analytics.track("editor-change", { tags: change.tags });
});
});All callback forms return void, must finish synchronously, and invalidate
tx when they return. Update callbacks, plugin write methods, and transaction
spec callbacks must not return a Promise or other thenable. An uncaught failure
discards the draft without publishing a commit. Escaped transactions are rejected.
A public update cannot be nested; pass the active tx into helpers instead.
A plain block array initializes the primary document. Pass
initialValue.children plus initialValue.roots when one editor owns extra
roots.
const editor = createEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
footer: [{ type: "paragraph", children: [{ text: "Internal" }] }],
},
},
});const editor = createEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
footer: [{ type: "paragraph", children: [{ text: "Internal" }] }],
},
},
});Read the primary document with editor.read.children(). Read an extra root by key.
const body = editor.read.children();
const footer = editor.read.root("footer");const body = editor.read.children();
const footer = editor.read.root("footer");Create, replace, or delete extra roots with tx.roots.
editor.update((tx) => {
tx.roots.create("aside:1", [
{ type: "paragraph", children: [{ text: "Aside" }] },
]);
});editor.update((tx) => {
tx.roots.create("aside:1", [
{ type: "paragraph", children: [{ text: "Aside" }] },
]);
});Use normal node and text transforms for the primary document. See Roots for React rendering, root chrome, and content roots.
editor.read.value() returns the persisted document value.
type EditorDocumentValue = {
children: Descendant[];
roots?: Record<string, Descendant[]>;
meta?: Record<string, unknown>;
};type EditorDocumentValue = {
children: Descendant[];
roots?: Record<string, Descendant[]>;
meta?: Record<string, unknown>;
};Use it for database persistence because it includes the primary document, extra roots, and persistent meta fields.
const documentValue = editor.read.value();const documentValue = editor.read.value();State fields are registered with defineStateField and read through
state.getField(field).
const title = editor.read((state) => state.getField(documentTitle));const title = editor.read((state) => state.getField(documentTitle));Write state fields with tx.setField.
editor.update((tx) => {
tx.setField(documentTitle, "Q3 Launch Brief");
});editor.update((tx) => {
tx.setField(documentTitle, "Q3 Launch Brief");
});State-field writes emit the field's typed effect. The commit lists the changed
field key in commit.dirtyStateKeys and carries the effect in commit.effects.
History inverts effects whose field policy is "add"; collaboration adapters
export effects whose field policy is "shared".
editor.update((tx) => {
tx.effects.emit(documentTitle.effect, {
previousValue: tx.getField(documentTitle),
value: remoteTitle,
});
});editor.update((tx) => {
tx.effects.emit(documentTitle.effect, {
previousValue: tx.getField(documentTitle),
value: remoteTitle,
});
});See Document Meta for persistence patterns and comments ownership.
Plugins declare element and property policy. Read the compiled schema through state.schema or tx.schema.
const behavior = editor.read((state) => ({
inline: state.schema.isInline(element),
isolating: state.schema.isIsolating(element),
keyboardSelectable: state.schema.isKeyboardSelectable(element),
void: state.schema.isVoid(element),
}));const behavior = editor.read((state) => ({
inline: state.schema.isInline(element),
isolating: state.schema.isIsolating(element),
keyboardSelectable: state.schema.isKeyboardSelectable(element),
void: state.schema.isVoid(element),
}));Use Schema for declarations, validation, fitting, and persisted identity.
Common schema checks include:
state.schema.allowsElementType(parentType, childType)state.schema.create(type, properties?)state.schema.createDefaultRootChild(root?)state.schema.delta()state.schema.element(type)state.schema.findWrapping(parent, child)state.schema.getElementBehavior(element)state.schema.getElementContentRoots(element)state.schema.getElementProperty(element, property)state.schema.getElementSlicePolicy(element)state.schema.getVocabulary()state.schema.identity()state.schema.isAtom(element)state.schema.isElementTypeInGroup(type, group)state.schema.isInline(element)state.schema.isIsolating(element)state.schema.isKeyboardSelectable(element)state.schema.isReadOnly(element)state.schema.isVoid(element)state.schema.isMarkableVoid(element)state.schema.isSelectable(element)state.schema.property({ key, placement, type? })state.schema.assertDocument(document)state.schema.assertFragment(children)state.schema.identity() always returns either a derived identity or a named
identity. Derived schemas omit id and version; named schemas provide both
for a durable lineage.
Resolved properties expose their value descriptor, target, placement, and
lifecycle. Value descriptors provide canonical defaults and structural JSON
equality. Resolved element content exposes allowedElementTypes, allowsText,
allowsUnknownElements, cardinality, and its canonical default.
Those compiled content facts also guard React projections. For example,
slots.externalText accepts only a non-void block whose resolved content and
live value contain exactly one direct Text. The renderer cannot opt an invalid
schema into that runtime.
Pass installed plugin descriptors to descriptor-aware schema APIs. Use a raw property query only for property keys discovered at runtime. Reading a default does not write that property into the document. The Plate value remains plain JSON until a transaction writes a field. See Schema for declaration, validation, and fitting.
Plugins expose mounted host and runtime services through editor.api.
editor.api.dom.focus();
editor.api.dom.clipboard.insertTextData(dataTransfer);editor.api.dom.focus();
editor.api.dom.clipboard.insertTextData(dataTransfer);Use api for services that are not transaction-scoped document mutations:
DOM/React bridges, clipboard ingress, mounted overlay handles, measurements, or
framework adapters. Do not put product editing commands there.
If a feature changes Plate model state, expose it as an update group.
Use editor.plugin(Plugin).api when the call site owns the plugin
descriptor and needs its typed API.
Subscribe to editor snapshots. The listener receives the current snapshot and an optional change summary.
const unsubscribe = editor.subscribe((_snapshot, commit) => {
if (commit?.changed.has("document") || commit?.dirtyStateKeys.length) {
const documentValue = editor.read.value();
save(documentValue);
}
});const unsubscribe = editor.subscribe((_snapshot, commit) => {
if (commit?.changed.has("document") || commit?.dirtyStateKeys.length) {
const documentValue = editor.read.value();
save(documentValue);
}
});Subscribe only to committed changes. The listener receives the change summary for each commit.
const unsubscribe = editor.subscribeCommit((commit) => {
if (commit.selectionChanged) {
syncSelection(commit.selectionAfter);
}
});const unsubscribe = editor.subscribeCommit((commit) => {
if (commit.selectionChanged) {
syncSelection(commit.selectionAfter);
}
});Call the returned function to unsubscribe.
Install statically known plugins when the editor is created. This preserves
their inferred read, update, and api groups.
const editor = createEditor({ plugins: [myPlugin] as const });const editor = createEditor({ plugins: [myPlugin] as const });Install a host-selected plugin after creation. Plate compiles and validates
a detached plugin candidate, publishes and activates it atomically, then
emits one commit. The commit includes the migrated document when options
provides one. Calling the returned cleanup function publishes the corresponding
removal through the same lifecycle.
const removePlugin = editor.install(myPlugin);
removePlugin();const removePlugin = editor.install(myPlugin);
removePlugin();When the plugin introduces a schema that rejects the current document, pass
options.migrate. It receives the immutable current document and candidate
schema, and returns the complete document to publish with the configuration.
Validation finishes before publication; failure publishes nothing.
Use creation-time installation when TypeScript should infer the plugin's groups. Dynamic installation does not rewrite the editor's static type.
Use a named slot for runtime configuration. Reconfiguration is part of one atomic editor transaction rather than an immediate registry mutation.
const feature = definePluginSlot("feature");
const editor = createEditor({
plugins: [feature.of(readMode)] as const,
});
editor.update((tx) => {
tx.plugins.reconfigure(feature, writeMode);
});const feature = definePluginSlot("feature");
const editor = createEditor({
plugins: [feature.of(readMode)] as const,
});
editor.update((tx) => {
tx.plugins.reconfigure(feature, writeMode);
});Plugins add typed state and tx namespaces. Direct-safe transaction
methods are also available through the matching editor.update group.
editor.update.links.toggle({ href });
editor.update((tx) => {
tx.links.toggle({ href });
});editor.update.links.toggle({ href });
editor.update((tx) => {
tx.links.toggle({ href });
});Wrap a transaction-only plugin method with txOnly(...). TypeScript omits
it from direct update groups, and dynamic direct dispatch rejects it at runtime.
Read-only utilities stay on their own frozen namespaces because they never mutate the editor. They operate on the immutable node, editor, and location values passed to them.
NodeApi.string(node);
ElementApi.isElement(value);
TextApi.isText(value);
PathApi.next(path);
PointApi.equals(point, other);
RangeApi.isCollapsed(range);
DocumentChange.fromJSON(value);NodeApi.string(node);
ElementApi.isElement(value);
TextApi.isText(value);
PathApi.next(path);
PointApi.equals(point, other);
RangeApi.isCollapsed(range);
DocumentChange.fromJSON(value);Use editor.read(...) when several state-dependent reads must share one
snapshot, and editor.update(...) for document changes.