One Lexical per app
lexical is effectively a singleton within one app: an app must resolve
exactly one copy of lexical, and one copy of each @lexical/* package it
uses. Two copies in the same bundle do not merely waste bytes — they break the
editor in ways whose error messages point nowhere near the real cause.
The practical consequence, covered in Publishing a library
below: a library built on Lexical declares lexical and the @lexical/*
packages it imports as peerDependencies, never as dependencies. The
application brings its own Lexical.
This page is about npm peerDependencies in your package.json. Extensions
have their own, unrelated notion of
peer dependencies — optional links between
extensions inside one editor.
The boundary is the app, not the page
Several self-contained apps can share a page, each with its own bundled copy of
Lexical, and that is fine — a CMS page with two independently deployed widgets
on it, or a micro-frontend setup, does not have a bug just because two copies of
lexical are loaded. Lexical anticipates this: isLexicalEditor() is an
instanceof check specifically "to prevent issues with multiple embedded
Lexical installations", and an editor is tagged on its own root element rather
than in any page-global registry, so each app's editors resolve to that app's
Lexical and ignore the others.
What those apps cannot do is interoperate. Two copies have no compatible API surface between them, so treat each as a sealed island:
- do not pass nodes, editors,
EditorStateobjects, selections or commands across the boundary — pass serialized JSON or HTML, which is data rather than identity; - a plugin, node package or shared editor config must be bundled with the app it serves, not shared as a live import between apps;
@lexical/reactcontext does not cross the boundary either, since the context object itself comes from one copy.
The rest of this page is about the failure case: two copies inside one app, where code from copy A is expected to work with an editor from copy B.
Why one copy per app
Lexical keeps state at module scope, and identifies things by reference rather than by name. Both assumptions hold only within a single copy of the module.
- The active editor is module state.
activeEditor,activeEditorStateandisReadOnlyModelive at module scope inLexicalUpdates.ts, and every$function reads them.editor.update()in copy A sets copy A's variables; a$function imported from copy B reads copy B's, which are stillnull(Editor State). - Node classes are compared by identity. Node registration and the
instanceof LexicalNodechecks compare class references. A node that extendsLexicalNodefrom copy A is not, as far as copy B is concerned, aLexicalNodeat all — even though both classes have the same name and come from the same published version (Nodes). - Commands are object identities.
createCommand()returns a fresh{type}object, andregisterCommand/dispatchCommandmatch on that object, not on itstypestring. Two copies oflexicalexport two distinctFORMAT_TEXT_COMMANDobjects, so a dispatch from one never reaches a listener registered with the other (Commands).
On top of that, two copies are usually two versions, which is API drift inside a single editor: half your plugins were compiled against a different API and serialization format than the editor running them, and the mismatch surfaces only at runtime, on the paths that happen to differ.
Symptoms of a duplicated Lexical
The errors are unhelpfully literal — a class that plainly does implement
getType() is reported as not implementing it, because the check is running
against the other copy:
LexicalNode: Node FooNode does not implement .getType().
Create node: Type heading in node HeadingNode does not match registered node
HeadingNode with the same type
createEditor: nodes[0] FooNode (type foo) is not a constructor that subclasses
LexicalNode from the lexical package used by this editor (0.50.0)
Unable to find an active editor state. State helpers or node methods can only be
used synchronously during the callback of editor.update(), editor.read(), or
editorState.read(). Detected on the page: 0 compatible editor(s) with version
0.50.0 and incompatible editors with versions 0.49.0
That last sentence is Lexical telling you directly that it found editors built
from another copy — a version different from its own, or the same version marked
(separately built, likely a bundler configuration issue). Note that this
diagnostic scans the whole page, so a genuinely separate app that ships its own
Lexical is counted there too; it is evidence of a bug only when those editors
were supposed to be part of your app.
Silent failures look like: a command that dispatches but never fires its
listener, $getSelection() returning null inside an update, or a custom node
rendering as plain text after a copy/paste. Real reports of this failure mode:
#4225 and
#7819.
Publishing a library
If you publish a package that imports from lexical or @lexical/* — a plugin,
a node package, a shared editor config, an in-house wrapper in a monorepo — put
those packages in peerDependencies, and in devDependencies so you can still
build and test:
{
"name": "my-lexical-plugin",
"peerDependencies": {
"lexical": ">=0.50.0",
"@lexical/react": ">=0.50.0",
"@lexical/utils": ">=0.50.0"
},
"devDependencies": {
"lexical": "0.50.0",
"@lexical/react": "0.50.0",
"@lexical/utils": "0.50.0"
}
}
Notes on the ranges:
- Lexical is pre-1.0, so
^0.50.0means>=0.50.0 <0.51.0— a caret peer range locks your users out of the next release. Prefer an open>=range (or an explicit>=0.50.0 <1.0.0) so an app can upgrade Lexical without waiting for you. - Mark a peer optional with
peerDependenciesMetawhen the import is behind an opt-in entry point, the way this repo's own packages marktypescriptoptional. - Do not add
lexicaltodependencies"just so it installs". That is exactly what pins a second copy undernode_modules/your-lib/node_modules/lexicalas soon as your range and the app's range disagree.
Application setup
The application owns the version, and every Lexical package it installs moves together:
-
Install
lexicaland each@lexical/*package you use at the same version. Published@lexical/*packages depend on the exact matchinglexicalversion (the monorepo'sworkspace:*is rewritten to an exact version at publish time), so@lexical/react@0.50.0besidelexical@0.49.0guarantees a nested second copy. -
Verify there is only one per app, from the app root:
npm ls lexical @lexical/react # or: pnpm why lexical / yarn why lexicalMore than one resolved version, or a path with a nested
node_modules/lexicalin it, is the bug. -
In a monorepo or a linked-package setup (
npm link,pnpm link, a Yalc copy), the linked library resolves Lexical from its ownnode_modulesand duplicates are the default rather than the exception. Deduplicate explicitly: hoist the dependency to the workspace root, or force resolution with pnpmoverrides, Yarnresolutions, ornpm dedupe. -
Bundlers can also be told to collapse duplicates — Vite
resolve.dedupe, webpackresolve.aliaspointing each package at one path:// vite.config.jsexport default {resolve: {dedupe: ['lexical', '@lexical/react', '@lexical/utils']},};Treat that as a safety net, not a fix: it collapses copies that are already version-compatible, and cannot rescue two genuinely different versions.
-
Duplication does not always come from the dependency tree: importing Lexical through both
importandrequirepulls in the ESM and the CJS build of the same version, which are still two copies. The React FAQ covers how this one shows up in@lexical/react.