Plite documents are JSON. Save the full document value when you need more than the primary editor body, such as extra roots, content roots, document titles, page settings, or other persistent meta fields.
This walkthrough uses Local Storage, but the same shape is what you send to your database.
Save The Full Document
Create the editor from the saved value, then persist the full document value after committed document or state-field changes.
import { useEffect, useState } from "react";
import { defineStateField } from "@platejs/plite";
import { Editable, Plite, usePliteEditor } from "@platejs/plite-react";
const STORAGE_KEY = "plite.document";
const documentTitle = defineStateField({
key: "document.title",
initial: () => "Untitled",
persist: true,
});
const fallbackValue = {
children: [
{
type: "paragraph",
children: [{ text: "A line of text in a paragraph." }],
},
],
meta: {
[documentTitle.key]: "Untitled",
},
};
const App = () => {
const [initialValue] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : fallbackValue;
});
const editor = usePliteEditor({
extensions: [documentTitle],
initialValue,
});
useEffect(() => {
return editor.subscribe((_snapshot, change) => {
if (!change) return;
if (!change.childrenChanged && change.dirtyStateKeys.length === 0) {
return;
}
const documentValue = editor.read.value();
localStorage.setItem(STORAGE_KEY, JSON.stringify(documentValue));
});
}, [editor]);
return (
<Plite editor={editor}>
<Editable />
</Plite>
);
};import { useEffect, useState } from "react";
import { defineStateField } from "@platejs/plite";
import { Editable, Plite, usePliteEditor } from "@platejs/plite-react";
const STORAGE_KEY = "plite.document";
const documentTitle = defineStateField({
key: "document.title",
initial: () => "Untitled",
persist: true,
});
const fallbackValue = {
children: [
{
type: "paragraph",
children: [{ text: "A line of text in a paragraph." }],
},
],
meta: {
[documentTitle.key]: "Untitled",
},
};
const App = () => {
const [initialValue] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : fallbackValue;
});
const editor = usePliteEditor({
extensions: [documentTitle],
initialValue,
});
useEffect(() => {
return editor.subscribe((_snapshot, change) => {
if (!change) return;
if (!change.childrenChanged && change.dirtyStateKeys.length === 0) {
return;
}
const documentValue = editor.read.value();
localStorage.setItem(STORAGE_KEY, JSON.stringify(documentValue));
});
}, [editor]);
return (
<Plite editor={editor}>
<Editable />
</Plite>
);
};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>;
};That shape includes the primary document, every extra root, and persistent meta
from state fields. It omits fields declared with persist: false.
Single-Root Shortcut
The first argument to <Plite onChange> is the provider root's block array.
You can save that value directly for a tiny single-root editor with no
persistent meta fields.
<Plite
editor={editor}
onChange={(value, change) => {
if (!change.valueChanged) return;
localStorage.setItem("plite.children", JSON.stringify(value));
}}
>
<Editable />
</Plite><Plite
editor={editor}
onChange={(value, change) => {
if (!change.valueChanged) return;
localStorage.setItem("plite.children", JSON.stringify(value));
}}
>
<Editable />
</Plite>Use the full document value once the editor owns extra roots, content roots, or state fields.
Load Extra Roots
Pass initialValue.children plus initialValue.roots when the saved document
owns extra roots.
const editor = usePliteEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
footer: [{ type: "paragraph", children: [{ text: "Internal" }] }],
},
},
});const editor = usePliteEditor({
initialValue: {
children: [{ type: "paragraph", children: [{ text: "Body" }] }],
roots: {
header: [{ type: "paragraph", children: [{ text: "Draft" }] }],
footer: [{ type: "paragraph", children: [{ text: "Internal" }] }],
},
},
});Render each root with Editable root.
<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>See Roots for content roots and root rendering.
Replace Saved Content
Create a new editor with initialValue: nextDocument when the user switches to
a different saved document. That is the clean path for arbitrary persisted
state fields.
Use editor.update for targeted in-place replacement when the app owns the
schema and can reconcile every root and every persistent meta field.
editor.update.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
});editor.update.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
});editor.update.value.replace replaces the active root snapshot. Pass
selection: "start" or selection: "end" when the imported document should
place the cursor at a document edge.
For an in-place multi-root replacement, use one transaction: replace the primary
children with tx.value.replace, create, replace, or delete extra roots with
tx.roots, and set or reset every persistent meta field your app registered
with tx.setField. This example assumes your app registered documentTitle
and pageSettings fields.
editor.update((tx) => {
const nextRoots = nextDocument.roots ?? {};
tx.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
});
const currentRoots = tx.value().roots ?? {};
for (const root of Object.keys(currentRoots)) {
if (!Object.hasOwn(nextRoots, root)) {
tx.roots.delete(root);
}
}
for (const [root, children] of Object.entries(nextRoots)) {
if (Object.hasOwn(currentRoots, root)) {
tx.roots.replace(root, children);
} else {
tx.roots.create(root, children);
}
}
tx.setField(
documentTitle,
nextDocument.meta?.[documentTitle.key] ?? "Untitled"
);
tx.setField(
pageSettings,
nextDocument.meta?.[pageSettings.key] ?? defaultPageSettings
);
});editor.update((tx) => {
const nextRoots = nextDocument.roots ?? {};
tx.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
});
const currentRoots = tx.value().roots ?? {};
for (const root of Object.keys(currentRoots)) {
if (!Object.hasOwn(nextRoots, root)) {
tx.roots.delete(root);
}
}
for (const [root, children] of Object.entries(nextRoots)) {
if (Object.hasOwn(currentRoots, root)) {
tx.roots.replace(root, children);
} else {
tx.roots.create(root, children);
}
}
tx.setField(
documentTitle,
nextDocument.meta?.[documentTitle.key] ?? "Untitled"
);
tx.setField(
pageSettings,
nextDocument.meta?.[pageSettings.key] ?? defaultPageSettings
);
});Do not leave registered persistent fields out of the reconciliation. Missing fields should be reset to your app defaults, otherwise the next save can mix metadata from two documents.
Store Comments Separately
Comment bodies, permissions, resolved state, and audit events belong to your app or collaboration service. The Plite document can store lightweight ids when comments need to travel with copied content, but the thread data should stay in the comment store.
Use Annotations to render comment anchors from that external store.
Persistence uses editor.read.value() for the whole document. Use value from
onChange only for single-root shortcuts, and keep external app data outside
the Plite document unless it is part of the document model.