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.<EditorRoot onValueChange>.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) | useEditor({ initialValue }) in React, or createEditor({ initialValue }) outside a component |
withHistory(editor) | history() in the editor's plugins array |
<Slate editor={editor} initialValue={value}> | const editor = useEditor({ initialValue }); <EditorRoot 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 plitejs |
ReactEditor.* DOM bridge calls | editor.api.dom.*, editor.api.react.*, or renderer hooks such as useElementPath() |
useSlate, useSlateStatic | useEditorContext() 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, persistent meta, and keyed records |
decorate ranges | A keyed DecorationSource passed to <EditorRoot decorations> |
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:
plitejs owns the editor runtime, document model, transactions, state fields,
and pure helper APIs.plitejs/dom owns DOM resolution, clipboard helpers, hotkeys, and browser
environment helpers.plitejs/react owns <EditorRoot>, <Editable>, render primitives, React hooks,
DOM repair, and the React editor plugin.plitejs/history owns the history plugin and history API.plitejs/hyperscript owns JSX fixtures for tests.plitejs/yjs owns Yjs admission, canonical change translation, optional
awareness projection, and remote cursor hooks. App code owns the provider,
readiness source, seed authority, connection state, cleanup, and server policy.For collaboration, migrate the provider lifetime before mounting the editor.
Pass doc and initialReady to yjs(...); pass awareness only when the
editor publishes presence. Gate the mounted editable on
useYjsAdmissionStatus(editor). Disconnect and reconnect the concrete provider
from app code, calling clearSelection() before detach and
syncSelection() after reconnect. Use seed: true only for one
app-selected initializer of a new empty room.
The editor owns its initial value. <EditorRoot> receives an editor that already
exists.
Slate 0.x:
import { useMemo } from "react";
const App = () => {
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
);
};import { useMemo } from "react";
const App = () => {
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
);
};Plite runtime:
import { Editable, EditorRoot, useEditor } from "plitejs/react";
const App = () => {
const editor = useEditor({ initialValue });
return (
<EditorRoot editor={editor}>
<Editable />
</EditorRoot>
);
};import { Editable, EditorRoot, useEditor } from "plitejs/react";
const App = () => {
const editor = useEditor({ initialValue });
return (
<EditorRoot editor={editor}>
<Editable />
</EditorRoot>
);
};Use createEditor when the editor must be created outside a component,
such as a test helper or app service.
import { createEditor } from "plitejs/react";
const editor = createEditor({ initialValue });import { createEditor } from "plitejs/react";
const editor = createEditor({ initialValue });Use createEditor plus plugins for lower-level runtime construction.
import { createEditor } from "plitejs";
import { dom } from "plitejs/dom";
import { history } from "plitejs/history";
import { react } from "plitejs/react";
const DOMPlugin = dom();
const editor = createEditor({
initialValue,
plugins: [react({ dom: DOMPlugin }), history()],
});import { createEditor } from "plitejs";
import { dom } from "plitejs/dom";
import { history } from "plitejs/history";
import { react } from "plitejs/react";
const DOMPlugin = dom();
const editor = createEditor({
initialValue,
plugins: [react({ dom: DOMPlugin }), 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 "plitejs";
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 "plitejs";
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 plitejs.
import { NodeApi, RangeApi, TextApi } from "plitejs";
const plainText = NodeApi.string(element);
const isCollapsed = selection ? RangeApi.isCollapsed(selection) : false;
const isText = TextApi.isText(node);import { NodeApi, RangeApi, TextApi } from "plitejs";
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 = useEditor({ initialValue });
return (
<EditorRoot editor={editor}>
<Editable renderElement={renderElement} />
</EditorRoot>
);
};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 = useEditor({ initialValue });
return (
<EditorRoot editor={editor}>
<Editable placeholder="Write something..." renderLeaf={renderLeaf} />
</EditorRoot>
);
};const renderLeaf = ({ attributes, children, leaf }) => {
return (
<span
{...attributes}
style={{ fontWeight: leaf.bold ? "bold" : undefined }}
>
{children}
</span>
);
};
const App = () => {
const editor = useEditor({ initialValue });
return (
<EditorRoot editor={editor}>
<Editable
Use EditorElement, EditorText, EditorLeaf, and EditorPlaceholder 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 |
|---|---|
useSlate() | useEditorContext() when you need the provider editor object |
useSlateSelector(...) | useEditorState(...) for state reads, or useEditorSelector(...) for low-level editor reads |
useSlateSelection() | useEditorSelection() |
useFocused() | useEditorFocused() |
useReadOnly() | useEditorReadOnly() |
useSelected() | useElementSelected(...) |
Toolbar state should subscribe to a derived value.
import { useEditorState } from "plitejs/react";
const BoldButton = () => {
const isBold = useEditorState((state) => {
return state.marks()?.bold === true;
});
return <button aria-pressed={isBold}>Bold</button>;
};import { useEditorState } from "plitejs/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 "plitejs/react";
const ImageElement = ({ attributes, children }) => {
const selected = useElementSelected({ mode: "collapsed" });
return (
<figure {...attributes} data-selected={selected ? "" : undefined}>
{children}
</figure>
);
};import { useElementSelected } from "plitejs/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 "slate-react";
const path = ReactEditor.findPath(editor, element);
const domNode = ReactEditor.toDOMNode(editor, element);
ReactEditor.focus(editor);import { ReactEditor } from "slate-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 "plitejs/react";
const BlockToolbarAnchor = () => {
const path = useElementPath();
return <span data-path={path?.join(".")} />;
};import { useElementPath } from "plitejs/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 plugin.
const composing = editor.api.react.isComposing();const composing = editor.api.react.isComposing();Install history() in the React editor's plugins array before using the history hook for UI controls.
import { useEditorHistory } from "plitejs/react";
const HistoryButtons = () => {
const history = useEditorHistory();
return (
<>
<button disabled={!history.canUndo} onClick={history.undo}>
Undo
</button>
<button disabled={!history.canRedo} onClick={history.redo}>
Redo
</button>
</>
);
};import { useEditorHistory } from "plitejs/react";
const HistoryButtons = () => {
const history = useEditorHistory();
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 plugin.
import { createEditor } from "plitejs";
import { history } from "plitejs/history";
const editor = createEditor({
initialValue,
plugins: [history()],
});import { createEditor } from "plitejs";
import { history } from "plitejs/history";
const editor = createEditor({
initialValue,
plugins: [history()],
});Omit history() when the editor does not need undo/redo. Use
history({ enabled: false }) to disable history supplied by a plugin preset.
import { history } from "plitejs/history";
import { useEditor } from "plitejs/react";
const App = () => {
const editor = useEditor({
initialValue,
plugins: [history({ enabled: false })],
});
return (
<EditorRoot editor={editor}>
<Editable />
</EditorRoot>
);
};import { history } from "plitejs/history";
import { useEditor } from "plitejs/react";
const App = () => {
const editor = useEditor({
initialValue,
plugins: [history({ enabled: false })],
});
return (
<EditorRoot editor={editor}>
<Editable />
</EditorRoot>
);
};For a single-root editor, save the value passed to <EditorRoot onValueChange>.
import { type ValueChangeContext } from "plitejs/react";
const handleChange = ({ value }: ValueChangeContext) => {
saveDocument(value);
};
<EditorRoot editor={editor} onValueChange={handleChange}>
<Editable />
</EditorRoot>;import { type ValueChangeContext } from "plitejs/react";
const handleChange = ({ value }: ValueChangeContext) => {
saveDocument(value);
};
<EditorRoot editor={editor} onValueChange={handleChange}>
<Editable />
</EditorRoot>;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": { value: "Draft", version: 1 },
},
};const initialValue = {
children: [
{
type: "paragraph",
children: [{ text: "A line of text!" }],
},
],
roots: {
header: [
{
type: "paragraph",
children: [{ text: "Draft" }],
},
],
},
meta: {
"document.title": { value: "Draft", version: 1 },
},
};Register the documentTitle field from the next step in plugins to decode
the title metadata. Omit root for the primary editable. Pass root only for extra roots.
<EditorRoot editor={editor}>
<Editable aria-label="Body" />
<Editable aria-label="Header" root="header" />
</EditorRoot><EditorRoot editor={editor}>
<Editable aria-label="Body" />
<Editable aria-label="Header" root="header" />
</EditorRoot>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 "plitejs";
import {
Editable,
EditorRoot,
useSetStateField,
useEditor,
useStateFieldValue,
} from "plitejs/react";
const documentTitle = defineStateField<string>({
key: "document.title",
initial: () => "Untitled",
persist: { ...valueCodecs.string, version: 1 },
});
const TitleInput = () => {
const title = useStateFieldValue(documentTitle);
const setTitle = useSetStateField(documentTitle);
return (
<input value={title} onChange={(event) => setTitle(event.target.value)} />
);
};
const App = () => {
const editor = useEditor({
plugins: [documentTitle],
initialValue,
});
return (
<EditorRoot editor={editor}>
<TitleInput />
<Editable />
</EditorRoot>
);
};import { defineStateField, valueCodecs } from "plitejs";
import {
Editable,
EditorRoot,
useSetStateField,
useEditor,
useStateFieldValue,
} from "plitejs/react";
const documentTitle = defineStateField<string>({
key: "document.title",
initial: () => "Untitled",
persist: { ...valueCodecs.string, version: 1 },
});
const TitleInput = () => {
const title = useStateFieldValue(documentTitle);
const setTitle
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 = useEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Header" }] }],
footer: [{ type: "paragraph", children: [{ text: "Footer" }] }],
},
},
});const editor = useEditor({
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.
<EditorRoot editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</EditorRoot><EditorRoot editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</EditorRoot>Use root-aware hooks for chrome and commands outside editable content.
import { useRootChrome, useRootEditor } from "plitejs/react";
const HeaderChrome = ({ children }) => {
const chrome = useRootChrome("header");
const headerEditor = useRootEditor("header");
return (
<section {...chrome.props}>
<button onClick={() => headerEditor.update.text.insert("!")}>Add</button>
{children}
</section>
);
};import { useRootChrome, useRootEditor } from "plitejs/react";
const HeaderChrome = ({ children }) => {
const chrome = useRootChrome("header");
const headerEditor = useRootEditor("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.
/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx } from "plitejs/hyperscript";
const editor = (
<editor>
<element type="paragraph">
alpha
<cursor />
</element>
</editor>
);/** @jsxRuntime classic */
/** @jsx jsx */
import { jsx } from "plitejs/hyperscript";
const editor = (
<editor>
<element type="paragraph">
alpha
<cursor />
</element>
</editor>
);Use createHyperscript when your test suite needs domain-specific tags.
import { createHyperscript } from "plitejs/hyperscript";
const h = createHyperscript({
elements: {
paragraph: { type: "paragraph" },
},
});
const paragraph = h("paragraph", {}, "hello");import { createHyperscript } from "plitejs/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 as createHeadlessEditor,
definePlugin,
defineStateField,
ElementApi,
NodeApi,
RangeApi,
TextApi,
} from "plitejs";
import { DOMCoverage, dom, Hotkeys, isHotkey } from "plitejs/dom";
import {
Editable,
EditorRoot,
createEditor,
useEditor,
useEditorContext,
useEditorState,
} from "plitejs/react";
import { history } from "plitejs/history";
import { createHyperscript, jsx } from "plitejs/hyperscript";import {
createEditor as createHeadlessEditor,
definePlugin,
defineStateField,
ElementApi,
NodeApi,
RangeApi,
TextApi,
} from "plitejs";
import { DOMCoverage, dom, Hotkeys, isHotkey } from "plitejs/dom";
import {
Editable,
EditorRoot,
createEditor,
useEditor,
useEditorContext,
useEditorState,
} from "plitejs/react";
import { history } from "plitejs/history";
import { createHyperscript, jsx } from "plitejs/hyperscript";The Editor export in plitejs/react includes the React and DOM capabilities.
Use editor.api.dom.* and editor.api.react.* instead of static bridge 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, persistent meta, and keyed records 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:
useEditor, <EditorRoot>, and
<Editable>.<EditorRoot onValueChange>.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.