Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Overview
    • Why This Fork
    • Examples
    • Installing Plite
    • Adding Event Handlers
    • Defining Custom Elements
    • Applying Custom Formatting
    • Executing Commands
    • Saving to a Database
    • Canonical Change Substrate
    • Improving Performance
    • Interfaces
    • Nodes
    • Locations
    • Transforms
    • Document Changes
    • Commands
    • Editor
    • Plugins
    • Rendering
    • Serializing
    • Normalizing
    • Using TypeScript
    • Roots
    • Document State
    • Editing Behavior
    • Selection And DOM
    • Clipboard And Paste
    • Decorations, annotations, and widgets
    • Schema
    • Anchor API
    • Location API
    • Path API
    • PointEntry API
    • Point API
    • Range API
    • Selection API
    • Location Types APIs
    • Span API
    • Editor
    • Element API
    • NodeEntry API
    • Node API
    • Node Types APIs
    • Text API
    • Debug Value Scrubbing
    • Transforms API
    • Plite DOM
    • History Editor API
    • History Plugin Setup
    • History
    • Plite History
    • Plite Hyperscript
    • Plite PaginationExperimental
    • Annotations
    • DOM Coverage Boundaries
    • Editable Component
    • External Text Views
    • Plite React Event Handling
    • Virtualized Rendering
    • Plite React Hooks
    • React Editor Setup
    • React Editor
    • Plite React
    • Plite Component
    • Plite Yjs
    • Plite
    • Migration
    • Contributing
    • FAQ
    • Resources

Migration

PreviousNext

Move an upstream Slate 0.x editor to the Plite runtime without keeping compatibility aliases.

This guide walks through moving an upstream Slate 0.x editor to the Plite runtime. The migration is mostly a runtime migration: create the editor with an initial value, read with editor.read(...), write with editor.update(...), and render through the React runtime.

On this page

  • Should You Migrate This Editor?
  • First Migration Slice
  • Migration Map
  • Step 1: Install The Packages
  • Step 14: Check Import Boundaries
  • Proof Before Deleting The Old Path
  • Validation Checklist
PliteContributing

On This Page

On this pageShould you migrate this editor?First migration sliceBefore you beginMigration mapStep 1: install the packagesStep 2: create the editorStep 3: move reads and writes into the runtimeStep 4: migrate custom elementsStep 5: migrate leaves, text, and placeholdersStep 6: migrate event handlersStep 7: migrate React hooksStep 8: migrate DOM and React editor callsStep 9: migrate historyStep 10: persist the right valueStep 11: migrate document metaStep 12: migrate extra rootsStep 13: migrate hyperscript testsStep 14: check import boundariesProof before deleting the old pathValidation checklistAPI reference links
Build your editor
Production-ready AI template and reusable components.
Get all-access

Should you migrate this editor?

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 firstWait 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.

First migration slice

Port the thinnest useful editor before touching every command or reusable feature:

  1. Create one Plite editor with the same representative document value.
  2. Render one <Editable> with the existing element and leaf renderers.
  3. Save the primary value in <EditorRoot onValueChange>.
  4. Port one toolbar or shortcut command to editor.read(...) and editor.update(...).
  5. Verify typing, selection, paste, undo/redo, and save/load in the browser.
  6. Delete only the old code covered by that proof.

Do not start with comments, collaboration, pagination, custom mobile input, or every reusable feature. Those belong after the basic editor behaves correctly.

Before you begin

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:

  • one representative document value;
  • the custom element and text types used by that editor;
  • the commands your app calls from toolbars, shortcuts, and paste handlers;
  • the tests that prove typing, selection, copy/paste, undo/redo, and save/load.

Do not start by porting every reusable feature. That is how migrations turn into archaeology.

Migration map

Slate 0.xPlite 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 callseditor.update.<group>.<method>() for one write, or editor.update((tx) => tx.*...) for grouped writes
Editor.* reads that inspect the current editoreditor.read.<group>.<method>() for one read, or editor.read((state) => state.*...) for grouped reads
Pure node, point, path, range, and text helpersNodeApi, ElementApi, PointApi, PathApi, RangeApi, and TextApi from plitejs
ReactEditor.* DOM bridge callseditor.api.dom.*, editor.api.react.*, or renderer hooks such as useElementPath()
useSlate, useSlateStaticuseEditorContext() and useEditorState(...)
useSelected, useFocused, useReadOnlyuseElementSelected(...), useEditorFocused(), and useEditorReadOnly()
onChange(value) as the primary persistence hookonValueChange({ value }), or editor.read.value() for the full document
editor-only children persistenceprimary children, optional extra roots, persistent meta, and keyed records
decorate rangesA keyed DecorationSource passed to <EditorRoot decorations>

Step 1: install the packages

For the beta package set, install the core editor, DOM helpers, React renderer, and React peer packages.

pnpm add plitejs react react-dom
pnpm add plitejs react react-dom

Install the optional packages only when your app imports them directly.

pnpm add platejs @tanstack/react-virtual diff-match-patch-ts yjs
pnpm add platejs @tanstack/react-virtual diff-match-patch-ts yjs

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.

Step 2: create the editor

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()],
});

Step 3: move reads and writes into the runtime

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);

Step 4: migrate custom elements

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.

Step 5: migrate leaves, text, and placeholders

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.

Step 6: migrate event handlers

Editable owns the default browser behavior. App handlers can run before Plite and decide whether Plite continues.

  • Return true when your handler handled the event and Plite should stop.
  • Return false when Plite should run its default handler.
  • Return nothing when Plite should decide from the DOM event state.

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;
  }}
/>

Step 7: migrate React hooks

Use editor hooks for editor-wide UI and element hooks for rendered nodes.

Slate 0.x hookPlite 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>
  );
};

Step 8: migrate DOM and React editor calls

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);
const root = ReactEditor.findDocumentOrShadowRoot(editor);
ReactEditor.focus(editor);
import { ReactEditor } from "slate-react";
 
const path = ReactEditor.findPath(editor, element);
const domNode = ReactEditor.toDOMNode(editor, element);
const root = ReactEditor.findDocumentOrShadowRoot(editor);
ReactEditor.focus(editor);

Plite runtime:

const path = editor.api.dom.resolvePath(element);
const root = editor.api.dom.resolveDocumentOrShadowRoot();
 
if (!path || !root) return;
 
editor.api.dom.focus();
const path = editor.api.dom.resolvePath(element);
const root = editor.api.dom.resolveDocumentOrShadowRoot();
 
if (!path || !root) 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();

Step 9: migrate history

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>
  );
};

Step 10: persist the right value

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>

Step 11: migrate document meta

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");
});

Step 12: migrate extra roots

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>
  );
};

Step 13: migrate hyperscript tests

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");

Step 14: check import boundaries

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.

Proof before deleting the old path

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.

BehaviorMinimum proof
Typing and deletionType text, Backspace/Delete, Enter, and follow-up typing after command changes.
SelectionCheck model selection and native selection for arrows, Shift+arrows, drag, double-click, and blank-space clicks when the surface supports them.
Clipboard and pasteVerify copy, cut, paste, and app-specific paste normalization through browser events.
Undo/redoProve command grouping, text input, paste, and structural edits undo and redo in the expected order.
Custom renderingVerify custom elements, leaves, voids, placeholders, hidden content, and DOM coverage paths used by the editor.
PersistenceSave 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.

Validation checklist

Run the migration in behavior order:

  1. Render the smallest editor with useEditor, <EditorRoot>, and <Editable>.
  2. Type normal text and save the primary value in <EditorRoot onValueChange>.
  3. Port one command to editor.read(...) plus editor.update(...).
  4. Port custom element and leaf renderers.
  5. Verify keyboard shortcuts, paste, copy, drag/drop, undo/redo, and selection.
  6. Persist editor.read.value() if the editor uses extra roots or document meta.
  7. Move tests to current public imports.
  8. Delete the old editor wrapper after the new editor owns the same behavior.

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.

API reference links

  • Installing Plite
  • Adding Event Handlers
  • Defining Custom Elements
  • Executing Commands
  • Saving to a Database
  • Editor
  • Transforms
  • Roots
  • Document Meta
  • Plite React Hooks
  • Plite React Event Handling
  • Plite History
  • Plite Hyperscript
  • Plite Yjs
"code"
:
return <CodeElement {...props} />;
default:
return <DefaultElement {...props} />;
}
};
const App = () => {
const editor = useEditor({ initialValue });
return (
<EditorRoot editor={editor}>
<Editable renderElement={renderElement} />
</EditorRoot>
);
};
placeholder
=
"Write something..."
renderLeaf
=
{renderLeaf} />
</EditorRoot>
);
};
=
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>
);
};