Multi-Editor
Run multiple independent editor instances on the same page.
Blokhaus supports multiple independent editor instances on the same page. Each editor has its own state, history, keyboard shortcuts, and overlay portals. This is useful for common patterns like separate title and body fields, side-by-side editing, or multi-section forms.
Basic setup
Each EditorRoot wraps its own LexicalComposer with a unique namespace. The namespace is a required prop that ensures complete isolation between editors:
"use client";
import {
EditorRoot,
FloatingToolbar,
SlashMenu,
OverlayPortal,
InputRulePlugin,
useEditorState,
} from "@blokhaus/core";
function TitleEditor() {
const { serializedState } = useEditorState({
onChange: (json) => console.log("Title:", json),
});
return null;
}
function BodyEditor() {
const { serializedState } = useEditorState({
onChange: (json) => console.log("Body:", json),
});
return null;
}
export default function EditorPage() {
return (
<div className="max-w-3xl mx-auto py-12 px-4 space-y-4">
{/* Title editor — minimal, no toolbar */}
<EditorRoot
namespace="title-editor"
className="text-3xl font-bold p-2 border-b"
placeholder="Untitled"
autoFocus={true}
>
<TitleEditor />
</EditorRoot>
{/* Body editor — full-featured */}
<EditorRoot
namespace="body-editor"
className="relative min-h-[500px] p-4 border rounded"
placeholder="Start writing..."
autoFocus={false}
>
<BodyEditor />
<FloatingToolbar />
<SlashMenu />
<OverlayPortal namespace="body-editor" />
<InputRulePlugin />
</EditorRoot>
</div>
);
}How isolation works
Separate LexicalComposer instances
Each EditorRoot creates its own LexicalComposer with an independent Lexical editor instance. This means:
- Each editor has its own AST (document state)
- Each editor has its own undo/redo history stack
- Each editor has its own registered nodes, plugins, and command listeners
- Mutations in one editor do not affect the other
Scoped keyboard shortcuts
Keyboard shortcuts like Cmd+B (bold) and Cmd+Z (undo) are scoped to the editor that currently has focus. This is handled automatically by Lexical's command priority system.
When you dispatch a command with COMMAND_PRIORITY_EDITOR or COMMAND_PRIORITY_LOW, the command only fires on the editor that owns the focused DOM element. There is no global shortcut interference between editors.
// This Bold command only affects the editor that has focus
editor.registerCommand(
FORMAT_TEXT_COMMAND,
(format) => {
// Only runs in the editor that received the keyboard event
return false;
},
COMMAND_PRIORITY_LOW,
);Independent overlay portals
Each OverlayPortal creates its own <div> appended to document.body as a portal mount point. The namespace is used to derive a unique element ID:
<OverlayPortal namespace="body-editor" />This ensures that the drag handle for "body-editor" only renders for nodes inside that editor. Hovering over nodes in a different editor will not trigger drag handles from the wrong portal.
No global singletons
Blokhaus never uses module-level mutable state or global singletons to track "the active editor." All state is scoped through React context provided by LexicalComposer. This is a strict architectural rule -- see the project architecture for details.
Using useEditorState in multi-editor setups
The useEditorState hook reads from the nearest LexicalComposer context. When you have multiple editors, place the hook usage inside each editor's component tree:
// This component must be a descendant of EditorRoot
function EditorStateReader({ label }: { label: string }) {
const { serializedState } = useEditorState({
debounceMs: 300,
onChange: (json) => {
console.log(`${label} state changed:`, json);
},
});
return <pre className="text-xs mt-2">{serializedState}</pre>;
}
export default function Page() {
return (
<>
<EditorRoot namespace="editor-a">
<EditorStateReader label="A" />
</EditorRoot>
<EditorRoot namespace="editor-b">
<EditorStateReader label="B" />
</EditorRoot>
</>
);
}Each EditorStateReader will only receive updates from its parent editor. Typing in editor A will not trigger the onChange callback in editor B.
Restoring state for multiple editors
Pass initialState to each editor independently:
const [titleState, setTitleState] = useState<string | null>(null);
const [bodyState, setBodyState] = useState<string | null>(null);
useEffect(() => {
// Load saved state from your backend
const saved = await fetchDocument(docId);
setTitleState(saved.title);
setBodyState(saved.body);
}, [docId]);
return (
<>
<EditorRoot namespace="title" initialState={titleState}>
{/* ... */}
</EditorRoot>
<EditorRoot namespace="body" initialState={bodyState}>
{/* ... */}
</EditorRoot>
</>
);Customizing plugins per editor
Because every feature is a composable React child, you can give each editor a different set of capabilities:
{
/* Title editor: plain text only, no formatting */
}
<EditorRoot namespace="title-editor">
{/* No FloatingToolbar, no SlashMenu, no InputRulePlugin */}
</EditorRoot>;
{
/* Body editor: full-featured */
}
<EditorRoot namespace="body-editor">
<FloatingToolbar />
<SlashMenu />
<OverlayPortal namespace="body-editor" />
<InputRulePlugin />
<ImagePlugin uploadHandler={uploadHandler} />
<PastePlugin />
<MentionPlugin providers={mentionProviders} />
<AIPlugin provider={aiProvider} />
</EditorRoot>;
{
/* Sidebar notes: limited features */
}
<EditorRoot namespace="sidebar-notes">
<FloatingToolbar />
<InputRulePlugin />
</EditorRoot>;Namespace requirements
The namespace prop must be:
- Unique across all editors on the same page
- Stable -- do not change the namespace after mount (it is used to create the
LexicalComposerinitial config, which does not support reconfiguration) - Descriptive -- use names that reflect the editor's purpose (e.g.,
"title-editor","body-editor","comment-123")
If you are rendering editors dynamically (e.g., a list of comments), use a unique identifier in the namespace:
{
comments.map((comment) => (
<EditorRoot
key={comment.id}
namespace={`comment-${comment.id}`}
initialState={comment.content}
>
<FloatingToolbar />
</EditorRoot>
));
}Performance considerations
Each EditorRoot creates a full Lexical editor instance with its own DOM reconciliation loop. For most use cases (2-5 editors), this has negligible performance impact.
If you need many editors on a single page (10+), consider:
- Lazy rendering: Only mount editors when they scroll into view
- Minimal plugins: Omit heavy plugins (like
OverlayPortalwith itsmousemovelistener) from editors that do not need them - Read-only mode: Use
editable={false}for editors that are display-only
<EditorRoot
namespace={`comment-${id}`}
initialState={content}
editable={false} // No editing overhead
autoFocus={false} // No auto-focus
>
{/* No plugins needed for read-only display */}
</EditorRoot>Related
- Serialization -- Saving and restoring editor state
- API: EditorRoot -- Full props reference
- API: OverlayPortal -- Portal isolation details