Slate 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 state 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/slate'
import { Editable, Slate, useSlateEditor } from '@platejs/slate-react'
const STORAGE_KEY = 'slate.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.' }],
},
],
state: {
[documentTitle.key]: 'Untitled',
},
}
const App = () => {
const [initialValue] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY)
return saved ? JSON.parse(saved) : fallbackValue
})
const editor = useSlateEditor({
extensions: [documentTitle],
initialValue,
})
useEffect(() => {
return editor.subscribe((_snapshot, change) => {
if (!change) return
if (!change.childrenChanged && change.dirtyStateKeys.length === 0) {
return
}
const documentValue = editor.read((state) => state.value.get())
localStorage.setItem(STORAGE_KEY, JSON.stringify(documentValue))
})
}, [editor])
return (
<Slate editor={editor}>
<Editable />
</Slate>
)
}import { useEffect, useState } from 'react'
import { defineStateField } from '@platejs/slate'
import { Editable, Slate, useSlateEditor } from '@platejs/slate-react'
const STORAGE_KEY = 'slate.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.' }],
},
],
state: {
[documentTitle.key]: 'Untitled',
},
}
const App = () => {
const [initialValue] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY)
return saved ? JSON.parse(saved) : fallbackValue
})
const editor = useSlateEditor({
extensions: [documentTitle],
initialValue,
})
useEffect(() => {
return editor.subscribe((_snapshot, change) => {
if (!change) return
if (!change.childrenChanged && change.dirtyStateKeys.length === 0) {
return
}
const documentValue = editor.read((state) => state.value.get())
localStorage.setItem(STORAGE_KEY, JSON.stringify(documentValue))
})
}, [editor])
return (
<Slate editor={editor}>
<Editable />
</Slate>
)
}state.value.get() returns the persisted document value:
type EditorDocumentValue = {
children: Descendant[]
roots?: Record<string, Descendant[]>
state?: Record<string, unknown>
}type EditorDocumentValue = {
children: Descendant[]
roots?: Record<string, Descendant[]>
state?: Record<string, unknown>
}That shape includes the primary document, every extra root, and persistent state
fields. It omits state fields declared with persist: false.
Single-Root Shortcut
The first argument to <Slate onChange> is the provider root's block array.
You can save that value directly for a tiny single-root editor with no
persistent state fields.
<Slate
editor={editor}
onChange={(value, change) => {
if (!change.valueChanged) return
localStorage.setItem('slate.children', JSON.stringify(value))
}}
>
<Editable />
</Slate><Slate
editor={editor}
onChange={(value, change) => {
if (!change.valueChanged) return
localStorage.setItem('slate.children', JSON.stringify(value))
}}
>
<Editable />
</Slate>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 = useSlateEditor({
initialValue: {
children: [{ type: 'paragraph', children: [{ text: 'Body' }] }],
roots: {
header: [{ type: 'paragraph', children: [{ text: 'Draft' }] }],
footer: [{ type: 'paragraph', children: [{ text: 'Internal' }] }],
},
},
})const editor = useSlateEditor({
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.
<Slate editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</Slate><Slate editor={editor}>
<Editable aria-label="Header" root="header" />
<Editable aria-label="Body" />
<Editable aria-label="Footer" root="footer" />
</Slate>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 state field.
editor.update((tx) => {
tx.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
})
})editor.update((tx) => {
tx.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
})
})tx.value.replace replaces the active root snapshot. For an in-place multi-root
replacement, create, replace, or delete extra roots with tx.roots, and set or
reset every persistent state 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.get().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.state?.[documentTitle.key] ?? 'Untitled'
)
tx.setField(
pageSettings,
nextDocument.state?.[pageSettings.key] ?? defaultPageSettings
)
})editor.update((tx) => {
const nextRoots = nextDocument.roots ?? {}
tx.value.replace({
children: nextDocument.children,
marks: null,
selection: null,
})
const currentRoots = tx.value.get().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.state?.[documentTitle.key] ?? 'Untitled'
)
tx.setField(
pageSettings,
nextDocument.state?.[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 Slate 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 state.value.get() for the whole document. Use value from
onChange only for single-root shortcuts, and keep external app data outside
the Slate document unless it is part of the document model.