Migrate when the runtime solves a real ownership problem in your editor. Do not migrate just to rename APIs.
Warning: this package set is a hard API cut. Do not migrate by keeping old helpers alive behind aliases. Move one surface at a time, keep the old editor working until the new editor has matching behavior, and delete each old path as soon as the replacement is verified.
| Migrate this surface first | Wait or defer |
|---|---|
| A representative editor that users type in, save, undo, and paste into. | A low-value editor with no active product pressure. |
| An editor that needs cleaner command ownership, document meta, overlays, extra roots, or large-document proof. | An editor whose only goal is to keep old Slate 0.x behavior with the smallest possible diff. |
| A surface where you can run browser proof for selection, paste, undo, and save/load. | A surface whose correctness depends on mobile-device proof, collaboration adapters, or production pagination that your app cannot verify yet. |
Keep the old editor path alive until the new path proves the same user-visible behavior. If that sounds expensive, the migration is not ready.
Port the thinnest useful editor before touching every command or reusable feature:
<Editable> with the existing element and leaf renderers.change.valueChanged.editor.read(...) and
editor.update(...).Do not start with comments, collaboration, pagination, custom mobile input, or every reusable feature. Those belong after the basic editor behaves correctly.
Start with the smallest editor surface that users can type in, save, and undo. Migrate commands, custom elements, and persistence after that editor is stable.
You should have:
Do not start by porting every reusable feature. That is how migrations turn into archaeology.
| Slate 0.x | Plite runtime |
|---|---|
createEditor() plus withReact(editor) | usePliteEditor({ initialValue }) in React, or createReactEditor({ initialValue }) outside a component |
withHistory(editor) | usePliteEditor(...) for the default React editor, or history() when building an editor manually |
<Plite editor={editor} initialValue={value}> | const editor = usePliteEditor({ initialValue }); <Plite editor={editor}> |
| Slate 0.x transform helper calls | editor.update.<group>.<method>() for one write, or editor.update((tx) => tx.*...) for grouped writes |
Editor.* reads that inspect the current editor | editor.read.<group>.<method>() for one read, or editor.read((state) => state.*...) for grouped reads |
| Pure node, point, path, range, and text helpers | NodeApi, ElementApi, PointApi, PathApi, RangeApi, and TextApi from @platejs/plite |
ReactEditor.* DOM bridge calls | editor.api.dom.*, editor.api.react.*, or renderer hooks such as useElementPath() |
usePlite, usePliteStatic | useEditor() and useEditorState(...) |
useSelected, useFocused, useReadOnly | useElementSelected(...), useEditorFocused(), and useEditorReadOnly() |
onChange(value) as the primary persistence hook | onValueChange({ value }), or editor.read.value() for the full document |
editor-only children persistence | primary children, optional extra roots, and optional document state |
decorate for simple local ranges | <Editable decorate={...} /> |
decorate for shared, external, frequent, or source-scoped overlays | usePliteDecorationSource, usePliteRangeDecorationSource, or usePliteAnnotationStore |
For the beta package set, install the core editor, DOM helpers, React renderer, and React peer packages.
Install the optional packages only when your app imports them directly.
Package ownership is split deliberately:
@platejs/plite owns the editor runtime, document model, transactions, state fields,
and pure helper APIs.@platejs/plite-dom owns DOM resolution, clipboard helpers, hotkeys, and browser
environment helpers.@platejs/plite-react owns <Plite>, <Editable>, render primitives, React hooks,
DOM repair, and the React editor extension.@platejs/plite-history owns the history extension and history API.@platejs/plite-hyperscript owns JSX fixtures for tests.@platejs/yjs owns the Yjs adapter, awareness, provider lifecycle bridge, and
remote cursor hooks. App code owns provider packages and server policy.The editor owns its initial value. <Plite> receives an editor that already
exists.
Slate 0.x:
import { useMemo } from "react";
const App = () => {
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
return (
<Plite editor={editor} initialValue={initialValue}>
<Editable />
</Plite>
);
};import { useMemo } from "react";
const App = () => {
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
return (
<Plite editor={editor} initialValue={initialValue}>
<Editable />
</Plite>
);
};Plite runtime:
import { Editable, Plite, usePliteEditor } from "@platejs/plite-react";
const App = () => {
const editor = usePliteEditor({ initialValue });
return (
<Plite editor={editor}>
<Editable />
</Plite>
);
};import { Editable, Plite, usePliteEditor } from "@platejs/plite-react";
const App = () => {
const editor = usePliteEditor({ initialValue });
return (
<Plite editor={editor}>
<Editable />
</Plite>
);
};Use createReactEditor when the editor must be created outside a component,
such as a test helper or app service.
import { createReactEditor } from "@platejs/plite-react";
const editor = createReactEditor({ initialValue });import { createReactEditor } from "@platejs/plite-react";
const editor = createReactEditor({ initialValue });Use createEditor plus extensions for lower-level runtime construction.
import { createEditor } from "@platejs/plite";
import { dom } from "@platejs/plite-dom";
import { history } from "@platejs/plite-history";
import { react } from "@platejs/plite-react";
const DOMExtension = dom();
const editor = createEditor({
initialValue,
extensions: [react({ dom: DOMExtension }), history()],
});import { createEditor } from "@platejs/plite";
import { dom } from "@platejs/plite-dom";
import { history } from "@platejs/plite-history";
import { react } from "@platejs/plite-react";
const DOMExtension = dom();
const editor = createEditor({
initialValue,
extensions: [react({ dom: DOMExtension }), history()],
});The runtime separates reads from writes. One-shot reads use
editor.read.<group>.<method>(), and grouped reads use editor.read(...).
One-shot writes use editor.update.<group>.<method>(), and grouped writes use
editor.update(...).
Slate 0.x commands often mixed read helpers and transform helpers directly against the editor object. In the Plate-maintained runtime, read the committed runtime state and write through a transaction.
Plite runtime:
import { ElementApi } from "@platejs/plite";
const isCodeBlockActive = editor.read((state) =>
Boolean(
state.nodes.find({
match: (node) => ElementApi.isElement(node) && node.type === "code",
})
)
);
editor.update((tx) => {
tx.blocks.set({ type: "code" });
});import { ElementApi } from "@platejs/plite";
const isCodeBlockActive = editor.read((state) =>
Boolean(
state.nodes.find({
match: (node) => ElementApi.isElement(node) && node.type === "code",
})
)
);
editor.update((tx) => {
tx.blocks.set({ type: "code" });
});For one-shot text commands, use direct update methods.
editor.update.text.insert("Hello");editor.update.text.insert("Hello");For grouped selection commands, write through tx.selection.
editor.update((tx) => {
tx.selection.set({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 },
});
});editor.update((tx) => {
tx.selection.set({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [0, 0], offset: 5 },
});
});For pure data checks, import the helper API from @platejs/plite.
import { NodeApi, RangeApi, TextApi } from "@platejs/plite";
const plainText = NodeApi.string(element);
const isCollapsed = selection ? RangeApi.isCollapsed(selection) : false;
const isText = TextApi.isText(node);import { NodeApi, RangeApi, TextApi } from "@platejs/plite";
const plainText = NodeApi.string(element);
const isCollapsed = selection ? RangeApi.isCollapsed(selection) : false;
const isText = TextApi.isText(node);Renderers are still React functions. The critical rule is unchanged: spread
attributes on the top-level DOM element and render children.
const CodeElement = ({ attributes, children }) => {
return (
<pre {...attributes}>
<code>{children}</code>
</pre>
);
};
const DefaultElement = ({ attributes, children }) => {
return <p {...attributes}>{children}</p>;
};
const renderElement = (props) => {
switch (props.element.type) {
case "code":
return <CodeElement {...props} />;
default:
return <DefaultElement {...props} />;
}
};
const App = () => {
const editor = usePliteEditor({ initialValue });
return (
<Plite editor={editor}>
<Editable renderElement={renderElement} />
</Plite>
);
};const CodeElement = ({ attributes, children }) => {
return (
<pre {...attributes}>
<code>{children}</code>
</pre>
);
};
const DefaultElement = ({ attributes, children }) => {
return <p {...attributes}>{children}</p>;
};
const renderElement = (props) => {
switch (props.element.type) {
case
Keep renderer functions stable. Define them at module scope or memoize them once.
Custom leaf and text renderers still receive Plite attributes and children. Keep the renderer small and pass Plite's props through.
const renderLeaf = ({ attributes, children, leaf }) => {
return (
<span
{...attributes}
style={{ fontWeight: leaf.bold ? "bold" : undefined }}
>
{children}
</span>
);
};
const App = () => {
const editor = usePliteEditor({ initialValue });
return (
<Plite editor={editor}>
<Editable placeholder="Write something..." renderLeaf={renderLeaf} />
</Plite>
);
};const renderLeaf = ({ attributes, children, leaf }) => {
return (
<span
{...attributes}
style={{ fontWeight: leaf.bold ? "bold" : undefined }}
>
{children}
</span>
);
};
const App = () => {
const editor = usePliteEditor({ initialValue });
return (
<Plite editor={editor}>
<Editable placeholder
Use PliteElement, PliteText, PliteLeaf, and PlitePlaceholder only when
you replace Plite's rendering primitives and still need the required DOM
attributes attached. Normal apps should start with renderElement, renderLeaf,
and placeholder.
Editable owns the default browser behavior. App handlers can run before Plite
and decide whether Plite continues.
true when your handler handled the event and Plite should stop.false when Plite should run its default handler.Slate 0.x event handlers often prevented the browser event and mutated text nodes directly. In the Plate-maintained runtime, return a handler decision and route the write through the editor runtime.
Plite runtime:
<Editable
onKeyDown={(event, { editor }) => {
if (!(event.metaKey && event.key === "b")) return false;
editor.update.marks.toggle("bold");
return true;
}}
/><Editable
onKeyDown={(event, { editor }) => {
if (!(event.metaKey && event.key === "b")) return false;
editor.update.marks.toggle("bold");
return true;
}}
/>Use onDOMBeforeInput for browser input types that need editor context.
<Editable
onDOMBeforeInput={(event, { editor, inputType }) => {
if (inputType !== "formatBold") return false;
editor.update.marks.toggle("bold");
return true;
}}
/><Editable
onDOMBeforeInput={(event, { editor, inputType }) => {
if (inputType !== "formatBold") return false;
editor.update.marks.toggle("bold");
return true;
}}
/>Use editor hooks for editor-wide UI and element hooks for rendered nodes.
| Slate 0.x hook | Plite hook |
|---|---|
usePlite() | useEditor() when you need the editor object |
usePliteSelector(...) | useEditorState(...) for state reads, or useEditorSelector(...) for low-level editor reads |
usePliteSelection() | useEditorSelection() |
useFocused() | useEditorFocused() |
useReadOnly() | useEditorReadOnly() |
useSelected() | useElementSelected(...) |
Toolbar state should subscribe to a derived value.
import { useEditorState } from "@platejs/plite-react";
const BoldButton = () => {
const isBold = useEditorState((state) => {
return state.marks()?.bold === true;
});
return <button aria-pressed={isBold}>Bold</button>;
};import { useEditorState } from "@platejs/plite-react";
const BoldButton = () => {
const isBold = useEditorState((state) => {
return state.marks()?.bold === true;
});
return <button aria-pressed={isBold}>Bold</button>;
};Rendered element UI should subscribe only when it draws selection state.
import { useElementSelected } from "@platejs/plite-react";
const ImageElement = ({ attributes, children }) => {
const selected = useElementSelected({ mode: "collapsed" });
return (
<figure {...attributes} data-selected={selected ? "" : undefined}>
{children}
</figure>
);
};import { useElementSelected } from "@platejs/plite-react";
const ImageElement = ({ attributes, children }) => {
const selected = useElementSelected({ mode: "collapsed" });
return (
<figure {...attributes} data-selected={selected ? "" : undefined}>
{children}
</figure>
);
};Do not import ReactEditor as a value object for DOM bridge calls. Use the
editor API or renderer hooks.
Slate 0.x:
import { ReactEditor } from "@platejs/plite-react";
const path = ReactEditor.findPath(editor, element);
const domNode = ReactEditor.toDOMNode(editor, element);
ReactEditor.focus(editor);import { ReactEditor } from "@platejs/plite-react";
const path = ReactEditor.findPath(editor, element);
const domNode = ReactEditor.toDOMNode(editor, element);
ReactEditor.focus(editor);Plite runtime:
const path = editor.api.dom.resolvePath(element);
if (!path) return;
editor.api.dom.focus();const path = editor.api.dom.resolvePath(element);
if (!path) return;
editor.api.dom.focus();Inside an element renderer, prefer useElementPath() when the component must
render path-derived UI.
import { useElementPath } from "@platejs/plite-react";
const BlockToolbarAnchor = () => {
const path = useElementPath();
return <span data-path={path?.join(".")} />;
};import { useElementPath } from "@platejs/plite-react";
const BlockToolbarAnchor = () => {
const path = useElementPath();
return <span data-path={path?.join(".")} />;
};Use editor.api.react.isComposing() for React runtime state that belongs to the
React extension.
const composing = editor.api.react.isComposing();const composing = editor.api.react.isComposing();The normal React setup includes history. Use the history hook for UI controls.
import { usePliteHistory } from "@platejs/plite-react";
const HistoryButtons = () => {
const history = usePliteHistory();
return (
<>
<button disabled={!history.canUndo} onClick={history.undo}>
Undo
</button>
<button disabled={!history.canRedo} onClick={history.redo}>
Redo
</button>
</>
);
};import { usePliteHistory } from "@platejs/plite-react";
const HistoryButtons = () => {
const history = usePliteHistory();
return (
<>
<button disabled={!history.canUndo} onClick={history.undo}>
Undo
</button>
<button disabled={!history.canRedo} onClick={history.redo}>
Redo
</button>
</>
);
};For a manually created editor, install the history extension.
import { createEditor } from "@platejs/plite";
import { history } from "@platejs/plite-history";
const editor = createEditor({
initialValue,
extensions: [history()],
});import { createEditor } from "@platejs/plite";
import { history } from "@platejs/plite-history";
const editor = createEditor({
initialValue,
extensions: [history()],
});Disable default history in a React editor when a test or app surface needs that explicitly.
import { history } from "@platejs/plite-history";
import { usePliteEditor } from "@platejs/plite-react";
const App = () => {
const editor = usePliteEditor({
initialValue,
extensions: [history({ enabled: false })],
});
return (
<Plite editor={editor}>
<Editable />
</Plite>
);
};import { history } from "@platejs/plite-history";
import { usePliteEditor } from "@platejs/plite-react";
const App = () => {
const editor = usePliteEditor({
initialValue,
extensions: [history({ enabled: false })],
});
return (
<Plite editor={editor}>
<Editable />
</Plite>
);
};For a single-root editor, save the value passed to <Plite onValueChange>.
import { type PliteValueChangeContext } from "@platejs/plite-react";
const handleChange = ({ value }: PliteValueChangeContext) => {
saveDocument(value);
};
<Plite editor={editor} onValueChange={handleChange}>
<Editable />
</Plite>;import { type PliteValueChangeContext } from "@platejs/plite-react";
const handleChange = ({ value }: PliteValueChangeContext) => {
saveDocument(value);
};
<Plite editor={editor} onValueChange={handleChange}>
<Editable />
</Plite>;For a document with extra roots or document meta, read the full document value.
const documentValue = editor.read.value();const documentValue = editor.read.value();The short value is still a block array.
const initialValue = [
{
type: "paragraph",
children: [{ text: "A line of text!" }],
},
];const initialValue = [
{
type: "paragraph",
children: [{ text: "A line of text!" }],
},
];Use the document value shape when the editor owns more than the primary document.
const initialValue = {
children: [
{
type: "paragraph",
children: [{ text: "A line of text!" }],
},
],
roots: {
header: [
{
type: "paragraph",
children: [{ text: "Draft" }],
},
],
},
meta: {
"document.title": "Draft",
},
};const initialValue = {
children: [
{
type: "paragraph",
children: [{ text: "A line of text!" }],
},
],
roots: {
header: [
{
type: "paragraph",
children: [{ text: "Draft" }],
},
],
},
meta: {
"document.title": "Draft",
},
};Omit root for the primary editable. Pass root only for extra roots.
<Plite editor={editor}>
<Editable aria-label="Body" />
<Editable aria-label="Header" root="header" />
</Plite><Plite editor={editor}>
<Editable aria-label="Body" />
<Editable aria-label="Header" root="header" />
</Plite>Use defineStateField for document-level state that should travel with the
document, such as title, layout settings, review mode, spellcheck metadata, or
collaboration-owned state.
import { defineStateField, valueCodecs } from "@platejs/plite";
import {
Editable,
Plite,
useSetStateField,
usePliteEditor,
useStateFieldValue,
} from "@platejs/plite-react";
const documentTitle = defineStateField<string>({
key: "document.title",
initial: () => "Untitled",
persist: valueCodecs.string,
});
const TitleInput = () => {
const title = useStateFieldValue(documentTitle);
const setTitle = useSetStateField(documentTitle);
return (
<input value={title} onChange={(event) => setTitle(event.target.value)} />
);
};
const App = () => {
const editor = usePliteEditor({
extensions: [documentTitle],
initialValue,
});
return (
<Plite editor={editor}>
<TitleInput />
<Editable />
</Plite>
);
};import { defineStateField, valueCodecs } from "@platejs/plite";
import {
Editable,
Plite,
useSetStateField,
usePliteEditor,
useStateFieldValue,
} from "@platejs/plite-react";
const documentTitle = defineStateField<string>({
key: "document.title",
initial: () => "Untitled",
persist: valueCodecs.string,
});
const TitleInput = () => {
const title = useStateFieldValue(documentTitle);
const setTitle = useSetStateField(documentTitle);
Read fields inside editor.read(...) and write fields inside
editor.update(...).
const title = editor.read((state) => state.getField(documentTitle));
editor.update((tx) => {
tx.setField(documentTitle, "Launch Brief");
});const title = editor.read((state) => state.getField(documentTitle));
editor.update((tx) => {
tx.setField(documentTitle, "Launch Brief");
});Use extra roots when one editor owns multiple editable regions that share history, schema, commands, and document meta.
const editor = usePliteEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Header" }] }],
footer: [{ type: "paragraph", children: [{ text: "Footer" }] }],
},
},
});const editor = usePliteEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Header" }] }],
footer: [{ type: "paragraph", children: [{ text: "Footer" }] }],
},
},
});Render the primary document without a root prop. Render extra roots by key.
<Plite editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</Plite><Plite editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</Plite>Use root-aware hooks for chrome and commands outside editable content.
import { usePliteRootChrome, usePliteRootEditor } from "@platejs/plite-react";
const HeaderChrome = ({ children }) => {
const chrome = usePliteRootChrome("header");
const headerEditor = usePliteRootEditor("header");
return (
<section {...chrome.props}>
<button onClick={() => headerEditor.update.text.insert("!")}>
Add
</button>
{children}
</section>
);
};import { usePliteRootChrome, usePliteRootEditor } from "@platejs/plite-react";
const HeaderChrome = ({ children }) => {
const chrome = usePliteRootChrome("header");
const headerEditor = usePliteRootEditor("header");
return (
<section {...chrome.props}>
<button onClick={() => headerEditor.update.text.insert("!")}>
Add
</button>
{children}
</section>
);
};Keep hyperscript in tests and fixtures. Runtime editor code should use normal Plite node values.
/** @jsx jsx */
import { jsx } from "@platejs/plite-hyperscript";
const editor = (
<editor>
<element type="paragraph">
alpha
<cursor />
</element>
</editor>
);/** @jsx jsx */
import { jsx } from "@platejs/plite-hyperscript";
const editor = (
<editor>
<element type="paragraph">
alpha
<cursor />
</element>
</editor>
);Use createHyperscript when your test suite needs domain-specific tags.
import { createHyperscript } from "@platejs/plite-hyperscript";
const h = createHyperscript({
elements: {
paragraph: { type: "paragraph" },
},
});
const paragraph = h("paragraph", {}, "hello");import { createHyperscript } from "@platejs/plite-hyperscript";
const h = createHyperscript({
elements: {
paragraph: { type: "paragraph" },
},
});
const paragraph = h("paragraph", {}, "hello");Use public package imports. Do not deep import from package internals.
import {
createEditor,
defineExtension,
defineStateField,
ElementApi,
NodeApi,
RangeApi,
TextApi,
} from "@platejs/plite";
import { DOMCoverage, dom, Hotkeys, isHotkey } from "@platejs/plite-dom";
import {
Editable,
Plite,
createReactEditor,
useEditor,
useEditorState,
usePliteEditor,
} from "@platejs/plite-react";
import { history } from "@platejs/plite-history";
import { createHyperscript, jsx } from "@platejs/plite-hyperscript";import {
createEditor,
defineExtension,
defineStateField,
ElementApi,
NodeApi,
RangeApi,
TextApi,
} from "@platejs/plite";
import { DOMCoverage, dom, Hotkeys, isHotkey } from "@platejs/plite-dom";
import {
Editable,
Plite,
createReactEditor,
useEditor,
useEditorState,
usePliteEditor,
} from "@platejs/plite-react";
import { history } from "@platejs/plite-history";
import { createHyperscript, jsx } from "@platejs/plite-hyperscript";The ReactEditor export in @platejs/plite-react is a type. Do not build runtime code
around ReactEditor.* static calls.
Delete old Plite code only after the new path proves the behavior users depend on. Model assertions are useful, but they do not prove browser selection, clipboard, focus, or DOM repair.
| Behavior | Minimum proof |
|---|---|
| Typing and deletion | Type text, Backspace/Delete, Enter, and follow-up typing after command changes. |
| Selection | Check model selection and native selection for arrows, Shift+arrows, drag, double-click, and blank-space clicks when the surface supports them. |
| Clipboard and paste | Verify copy, cut, paste, and app-specific paste normalization through browser events. |
| Undo/redo | Prove command grouping, text input, paste, and structural edits undo and redo in the expected order. |
| Custom rendering | Verify custom elements, leaves, voids, placeholders, hidden content, and DOM coverage paths used by the editor. |
| Persistence | Save and reload the exact value shape the editor owns: primary children, plus extra roots or document state when used. |
For selection-sensitive editors, run browser proof before deleting the old path. For app-only commands, focused unit tests can be enough after one browser path has proven the runtime integration.
Run the migration in behavior order:
usePliteEditor, <Plite>, and
<Editable>.change.valueChanged is true.editor.read(...) plus editor.update(...).editor.read.value() if the editor uses extra roots or document meta.The migration is complete when the editor behaves the same from the user's point of view and the codebase no longer depends on old helper aliases.