docs/plans/2026-05-04-slate-v2-react-editor-initialization-value-ralplan.md
Score: 0.93, status: ready-for-user-review.
Hard take: the current example code is too much, and the root cause is real.
<Slate initialValue> mutates the editor during provider render, while
projection/decoration stores can read the editor snapshot before that provider
initialization runs. That forces manual pre-seeding with
editor.update((tx) => tx.value.replace(...)), which is correct mechanically but
bad public DX.
Target direction:
const editor = useSlateEditor({
initialValue,
withEditor: withHistory,
});
return (
<Slate editor={editor} decorationSources={[codeHighlightingSource]}>
<Editable />
</Slate>
);
Hard revision from the API bake-off:
withEditor, not a composer array.withHistory should mirror withReact and
preserve ValueOf<T>.as const, or explicit generic
assertions.Low-level non-React target:
const editor = createEditor<CustomValue>({
initialValue,
initialSelection: null,
});
This steals the right idea from Lexical, Tiptap, and ProseMirror: initial document state belongs to editor/state creation, not to a post-construction manual transaction hidden in app code.
Intent:
tx.value.replace boilerplate.Outcome:
editor.update seeding.<Slate> stops being the primary value initializer.In scope:
createEditor({ initialValue, initialSelection }).useSlateEditor({ initialValue, withEditor }) in slate-react.Non-goals:
slate-react depend on slate-history.editor.update or tx.value.replace; they remain the correct
mounted-editor replacement path.plugins the raw Slate initialization API.Decision boundaries:
<Slate initialValue> after migration
proof.Resolved by this pass:
<Slate initialValue> should not be the blessed v2 initialization API.Current code-highlighting example manually creates, seeds, and returns the editor:
/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx:55/Users/zbeyens/git/slate-v2/site/examples/ts/code-highlighting.tsx:61Current provider accepts initialValue:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx:65/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx:96/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx:107Current provider initialization mutates the editor in render via
editor.update -> tx.value.replace, then marks the editor initialized in a
WeakSet:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx:96/Users/zbeyens/git/slate-v2/packages/slate-react/src/components/slate.tsx:107Current projection stores build immediately from the editor snapshot when the source is created:
/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts:343/Users/zbeyens/git/slate-v2/packages/slate-react/src/projection-store.ts:352That is why examples with decoration/projection stores are tempted to seed the
editor before <Slate> renders.
Current core createEditor takes no options and initializes public state to an
empty document:
/Users/zbeyens/git/slate-v2/packages/slate/src/create-editor.ts:385/Users/zbeyens/git/slate-v2/packages/slate/src/create-editor.ts:508/Users/zbeyens/git/slate-v2/packages/slate/src/core/public-state.ts:2474Lexical:
LexicalComposer creates the editor inside useMemo and calls
initializeEditor(editor, initialEditorState) before returning context:
/Users/zbeyens/git/lexical/packages/lexical-react/src/LexicalComposer.tsx:57
and
/Users/zbeyens/git/lexical/packages/lexical-react/src/LexicalComposer.tsx:82.initialConfig.editorState is the React entrypoint:
/Users/zbeyens/git/lexical/packages/lexical-react/src/LexicalComposer.tsx:45./Users/zbeyens/git/lexical/packages/lexical-react/src/LexicalComposer.tsx:110.Tiptap:
useEditor owns editor creation from options:
/Users/zbeyens/git/tiptap/packages/react/src/useEditor.ts:344.EditorOptions.content is the initial document input:
/Users/zbeyens/git/tiptap/packages/core/src/types.ts:296.useSyncExternalStore and has explicit rerender controls:
/Users/zbeyens/git/tiptap/packages/react/src/useEditor.ts:351 and
/Users/zbeyens/git/tiptap/packages/react/src/useEditor.ts:365.ProseMirror:
EditorState.create({ doc, plugins }), then new EditorView({ state }).
Evidence:
/Users/zbeyens/git/prosemirror/demo/demo.ts:13 and
/Users/zbeyens/git/prosemirror/demo/demo.ts:16.Conclusion:
content name for raw Slate. Slate's term is
initialValue.Principles:
initialValue, not content.Top drivers:
Options:
<Slate initialValue> as the blessed API.
createEditor({ initialValue }).
useMemo plus composer nesting.useSlateEditor({ initialValue, withEditor }).
with* composition.useEditor already
means context in Slate.useSlateEditor({ initialValue, withEditors: [...] }).
HistoryEditor without extra tuple machinery or explicit
generics. It also implies a plugin list instead of Slate's existing
composer-function model.useEditor({ content, extensions }).
useEditor, and
implies an opinionated extension architecture.<SlateComposer initialConfig>.
<Slate>.Chosen:
createEditor({ initialValue, initialSelection }).useSlateEditor({ initialValue, withEditor }).withHistory typing to preserve the editor value and
extension intersection through ValueOf<T>, matching withReact.<Slate editor={editor}> should provide context and subscriptions,
not initialize document content.Consequences:
<Slate initialValue> tests need migration or a compatibility
decision.withEditor and adds the withHistory
ValueOf<T> typing prerequisite.Follow-ups:
withHistory preserves ValueOf<T>.withEditor option.Core:
type CreateEditorOptions<V extends Value = Value> = {
initialSelection?: Selection;
initialValue?: V;
};
createEditor<CustomValue>({
initialValue,
initialSelection: null,
});
React:
type SlateEditorComposer<
V extends Value,
E extends ReactEditor<V> = ReactEditor<V>,
> = (editor: ReactEditor<V>) => E;
type UseSlateEditorOptions<
V extends Value,
E extends ReactEditor<V> = ReactEditor<V>,
> = {
withEditor?: SlateEditorComposer<V, E>;
initialSelection?: Selection;
initialValue?: V;
};
const editor = useSlateEditor({
initialValue,
withEditor: withHistory,
});
Support composer typing target:
export const withHistory = <T extends Editor<any>>(
editor: T
): T & HistoryEditor<ValueOf<T>>
That shape is not optional polish. The current withHistory signature loses the
custom value/editor intersection in TypeScript and forces casts in examples.
Docs examples should show:
const editor = useSlateEditor({
initialValue,
withEditor: withHistory,
});
return (
<Slate editor={editor}>
<Editable />
</Slate>
);
Multiple editor wrappers stay explicit composition:
const editor = useSlateEditor({
initialValue,
withEditor: (editor) => withFoo(withHistory(editor)),
});
If multiple composition becomes common, add a tiny composeEditors
helper later. Do not make the first public hook carry an array DSL.
Mounted replacement remains:
editor.update((tx) => {
tx.value.replace({
children: nextValue,
selection: null,
});
});
That is not initialization. It is explicit document replacement.
Primary:
#5488: real API ergonomics pain around replacing editor content without
fake controlled value loops.
Evidence: docs/slate-issues/open-issues-dossiers/5558-5480.md:886.#5710: load different content; current answer is a pile of transforms or
direct children replacement.
Evidence: docs/slate-issues/open-issues-dossiers/5760-5666.md:464.#4564: programmatic clearing leaves selection pointing into vanished
content and crashes.
Evidence: docs/slate-issues/open-issues-dossiers/4642-4542.md:954.#3465: normalization for initial value/imported documents.
Evidence: docs/slate-issues/open-issues-dossiers/3558-3435.md:721.Adjacent:
#4612: externally updating editor value in slate-react; not a direct test
candidate, but relevant API pressure.
Evidence: docs/slate-issues/test-candidate-map/4642-4542.md:125.#5351: empty array as an initial value breaks; docs/API should make the
valid initial value contract impossible to miss.
Evidence: docs/slate-issues/open-issues-dossiers/5402-5250.md:646.Do not overclaim:
initializePublicState(editor, options) should seed children, runtime ids,
selection, marks, operations, commit, caches, and version coherently.onChange, or enter
history.initialValue should fail with a clear error before React renders.[] with a better error and docs;[] for raw Slate unless a schema/default
element exists.useSlateEditor creates one editor per component lifetime.withReact internally.withEditor after withReact, so withEditor: withHistory
gives withHistory(withReact(createEditor(...))).useSyncExternalStore discipline only where needed;
Slate already has provider-level selector contexts, so do not add a second
subscription model casually.Rejected:
useEditor: already means context in Slate and would be a breaking semantic
collision.useCreateEditor: accurate but clunky.useEditorInstance: internal-sounding.useSlate: too vague and close to legacy naming confusion.withEditors / composer arrays: attractive but wrong for first public API; array typing failed the
bake-off and implies a plugin list.plugins / extensions: too Tiptap/Plate-shaped for raw Slate.setup / configure: vague and side-effect flavored.with: cute, but too terse for a public option name.Accepted names:
useSlateEditorwithEditorReason:
useEditor.withReact / withHistory function composition.Unit/API:
createEditor({ initialValue }) snapshot has the initialized children before
any update.createEditor({ initialValue, initialSelection }) preserves or nulls
selection deterministically.initialValue throws a clear public error.[] behavior is explicitly tested according to the chosen contract.React:
withHistory(withReact(createEditor<CustomValue>())) preserves
CustomValue, ReactEditor, and HistoryEditor without casts.useSlateEditor({ initialValue, withEditor: withHistory }) returns a
React-enabled editor with initialized value.useSlateEditor({ initialValue, withEditor: editor => withFoo(withHistory(editor)) })
keeps callback parameters typed.useSlateEditor sees initialized
text in its first build.<Slate editor={editor}> no longer needs initialValue.<Slate initialValue> remains temporarily, duplicate initialization warns
or no-ops deterministically.Issue regressions:
#5488/#5710: docs and tests show how to replace a whole document after
mount with selection reset.#4564: replacing/clearing content with selection: null never leaves DOM
selection pointing into removed content.#3465: initial/imported normalization contract is tested or explicitly
rejected with an error.Browser:
vercel-react-best-practices: applied.
react-useeffect: applied.
performance-oracle: applied.
performance: applied.
tdd: applied.
build-web-apps:shadcn: skipped.
Change: Add createEditor({ initialValue }).
<Slate initialValue>; why duplicate?"Editor.getSnapshot(editor)
during source creation.tx.value.replace in examples. It teaches
setup as a mutation and confuses agents.initialValue from <Slate> into createEditor or
useSlateEditor.Change: Add useSlateEditor.
useMemo(() => withReact(createEditor())).withReact ordering and one optional composition
callback.useMemo/useState editor setup with useSlateEditor.withEditor.Change: Reject withEditors / composer arrays.
withEditors: [withHistory, withFoo] looks nice in examples.withEditor is less list-like for multi-wrapper setups.HistoryEditor without tuple machinery, while withEditor: withHistory passed
once withHistory used ValueOf<T>..tmp/slate-v2 paths:
current withHistory chain fails; ValueOf<T> withHistory plus
withEditor: withHistory passes; variadic composer arrays still fail.as const. Too much ceremony
for raw Slate's first hook.withEditor: editor => withFoo(withHistory(editor)).Change: Fix withHistory typing.
CustomEditor and move on.slate-history before the React hook.withHistory(withReact(createEditor<CustomValue>())) fails
type proof; withReact already uses ValueOf<T> and passes the preservation
pattern.useSlateEditor<CustomValue, CustomEditor>.
Generic assertions are not better DX than the current cast.Change: Demote or remove <Slate initialValue>.
<Slate initialValue>.editor.update inside render-time
initialization.initialValue moves to useSlateEditor or createEditor.Phase 0: support composer type substrate.
withHistory to preserve ValueOf<T> like withReact.withHistory(withReact(createEditor<CustomValue>()));useSlateEditor({ initialValue, withEditor: withHistory });useSlateEditor({ initialValue, withEditor: editor => withFoo(withHistory(editor)) }).Phase 1: core initialization.
CreateEditorOptions.Phase 2: React hook.
useSlateEditor.withReact internally.withEditor.Phase 3: provider cleanup.
<Slate initialValue> to initialized editors.initialValue from the public docs/API target before v2
public release. If migration needs a short-lived adapter, keep it undocumented
and warn in development.tx.value.replace in examples.Phase 4: issue tests.
#5488/#5710 whole-document replacement docs/test.#4564 selection reset test.#3465/#5351 initial value validity/normalization contract.Phase 5: browser proof.
| Pass | Status | Evidence Added | Delta | Open |
|---|---|---|---|---|
| current-state read | complete | live code-highlighting, Slate provider, projection store, createEditor | identified provider-late initialization bug | none |
| ecosystem comparison | complete | local Lexical, Tiptap, ProseMirror source | chose editor/state creation over provider render | none |
| issue mining | complete | #5488, #5710, #4564, #3465, #5351 | added issue-solve targets | none |
| API bake-off | complete | in-memory TypeScript proof against live .tmp/slate-v2 package paths | rejected withEditors / composer arrays, accepted withEditor, added withHistory typing prerequisite | none |
| closure score | complete | revised public API target, objection ledger, phases, and scorecard | plan reaches 0.93 | none |
| Ralph execution | complete | createEditor options, useSlateEditor, withHistory generic preservation, provider cleanup, example migration, focused tests, browser proof | active plan executed in .tmp/slate-v2; dynamic import errors surfaced; production Editor.isEditor chunk-order crash fixed | user review |
| Dimension | Score | Evidence |
|---|---|---|
| React runtime performance | 0.94 | provider render mutation cut, projection immediate build, construction-time initialization |
| Slate-close DX | 0.93 | initialValue, withEditor, existing withReact / withHistory composition |
| Plate/slate-yjs backbone | 0.90 | replacement transactions stay explicit; no controlled React value API |
| Regression-proof testing | 0.92 | type tests, initial snapshot tests, replacement selection tests, issue-targeted browser/examples |
| Research evidence | 0.92 | local Lexical/Tiptap/ProseMirror source read plus live Slate v2 TypeScript bake-off |
| composability/minimalism | 0.95 | single hook, singular composition callback, no array/plugin DSL |
Total: 0.93.
Completion is done: the API bake-off proved the hook signature, and Ralph
execution shipped the core, React, example, test, and browser-proof slices.
Ready for user review. No autonomous next pass remains for this plan.