dio.la

Dani Guardiola’s blog

Nov 21, 2023November 21st, 2023 · 10 minute read · tweet

Lexical state updates

A deep dive into how Lexical updates its state.

This article's main image

Lexical is a popular JavaScript text editor framework.

State updates are one of its core concepts, and Lexical has a very unique take on them. This article will demystify how they work.

If you are a Lexical user, you will walk away with a deeper understanding of it that'll help you build better text editors. And if you aren't, you will still add a clever JavaScript technique to your toolbelt.

What is an update?

A Lexical state (EditorState) holds two important pieces of data: the current node tree and the selection state. An editor instance (LexicalEditor) always contains a state (the "active" state).

The state (not the DOM) is the source of truth for the editor. The DOM is always computed from the state and never the other way around. A fancier way to put it is:

The view (DOM) is a function of the state (EditorState).

view as a function of the state

When the state is updated, Lexical computes the differences between the previous and next states and then applies them to the DOM in a process called "reconciliation".

state update and reconciliation

In this simplified example, the previous state had two paragraphs, while the next one only has one. After comparing them, Lexical knows that the second paragraph needs to be removed, so it removes it from the DOM.

This resembles the concept of "virtual DOM diffing" from other projects like React, although in practice there are many differences.


Lexical triggers updates internally in multiple situations. Some examples:

LexicalEditor.update()

It is also possible to update the state manually, by executing the LexicalEditor.update method with a callback (the "update function"). Here's an example from the docs:

ts
const editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {
// Get the RootNode from the EditorState. const const root: RootNoderoot = $getRoot(): RootNode$getRoot(); // Create a new ParagraphNode. const const paragraphNode: ParagraphNodeparagraphNode = $createParagraphNode(): ParagraphNode$createParagraphNode(); // Create a new TextNode. const const textNode: TextNodetextNode = $createTextNode(text?: string): TextNode$createTextNode("Hello world"); // Append the text node to the paragraph. const paragraphNode: ParagraphNodeparagraphNode.ElementNode.append(...nodesToAppend: LexicalNode[]): ParagraphNodeappend(const textNode: TextNodetextNode); // Finally, append the paragraph to the root. const root: RootNoderoot.RootNode.append(...nodesToAppend: LexicalNode[]): RootNodeappend(const paragraphNode: ParagraphNodeparagraphNode);});
Open in playground

This is one of the most important features in Lexical: the ability to programatically update the state.

For example, if you want to build an "insert" button to add a node like "image" or "video", you need to update the state*.

* Realistically, this would probably be implemented by dispatching an "insert" command, but even in that case, the command listener will update the state.


The code example above prompts a few questions:

  • We're not returning anything, nor calling any sort of setState function.

    How is the resulting state persisted?

  • We might have different editor instances on our app loaded at the same time.

    How does $getRoot() know which editor to get the root node from?

  • ...is this PHP? jQuery?!?

    What are these $ methods?

We'll get to the answers soon (spoiler: they're all related to lexical scope), but we need to lay the groundwork first.

The update lifecycle

It's sometimes easier to think of state as something that is "updated" or "changed". Conceptually, that's what happens.

In reality, the state of an editor instance is not updated: it's replaced with a new state. Editor states are immutable by default and cannot be altered directly.


These are roughly the steps that happen when an update happens:

  • The current state is cloned. This cloned state becomes the "pending state".
  • The pending state becomes ✔ mutable.
  • The update function applies changes to the pending state.
  • Once the update function is done, the pending state is 🔒 locked (made immutable).
  • Reconciliation happens and the resulting changes are applied to the DOM.
  • The pending state is set as the current state, effectively replacing it.

While this doesn't answer our questions yet, it offers a hint: the pending state is mutable while the update function is running.

We now know that it is possible to mutate the pending state, but we still don't know how.

Anatomy of an update

To get a clearer picture of what's going on, let's create a simplified version of the method that executes an update in Lexical. LexicalEditor.update() calls this method internally.

Here's a skeleton implementation:

ts
function function updateEditor(editor: LexicalEditor, updateFn: () => void): voidupdateEditor(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor, updateFn: () => voidupdateFn: () => void) { // Get the current state. const currentEditorState = editor: LexicalEditoreditor.LexicalEditor._editorState: EditorState
@internal
_editorState
;
const currentEditorState: EditorState
// Clone the current state and set it as the pending state. editor: LexicalEditoreditor.LexicalEditor._pendingEditorState: EditorState | null
@internal
_pendingEditorState
= function cloneEditorState(current: EditorState): EditorStatecloneEditorState(const currentEditorState: EditorStatecurrentEditorState);
// Enable mutability. let isReadOnlyMode: booleanisReadOnlyMode = false; // Execute the update function. updateFn: () => voidupdateFn(); // Disable mutability. let isReadOnlyMode: booleanisReadOnlyMode = true; // Commit pending updates. function commitPendingUpdates(editor: LexicalEditor): voidcommitPendingUpdates(editor: LexicalEditoreditor);}
Open in playground

There are two distinct phases:

  1. Update phase: the state is cloned and the update function is executed.
  2. Commit phase: reconciliation happens, and the new state replaces the old one.

Reconciliation is the process of comparing the pending and current states, and applying any necessary changes to the DOM.

The second phase happens entirely inside of commitPendingUpdates. Here's an extremely simplified representation of what it does:

ts
function function commitPendingUpdates(editor: LexicalEditor): voidcommitPendingUpdates(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor) { // Get the current state. const const currentState: EditorStatecurrentState = editor: LexicalEditoreditor.LexicalEditor._editorState: EditorState
@internal
_editorState
;
// Get the pending state. const const pendingState: EditorStatependingState = editor: LexicalEditoreditor.LexicalEditor._pendingEditorState: EditorState | null
@internal
_pendingEditorState
!;
// Reconcile the DOM. function reconcileDOM(currentState: EditorState, pendingState: EditorState): voidreconcileDOM(const currentState: EditorStatecurrentState, const pendingState: EditorStatependingState); // Set the pending state as the current state. editor: LexicalEditoreditor.LexicalEditor._editorState: EditorState
@internal
_editorState
= const pendingState: EditorStatependingState;
}
Open in playground

The lexical scope

Something that stands out here is isReadOnlyMode. Where is it coming from?

Simple: it's defined at the module level. The LexicalUpdates.ts module contains both the isReadOnlyMode variable declaration and the updateEditor function:

ts
let let isReadOnlyMode: booleanisReadOnlyMode = true;export function function updateEditor(editor: LexicalEditor, updateFn: () => void): voidupdateEditor(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor, updateFn: () => voidupdateFn: () => void) { // ... let isReadOnlyMode: booleanisReadOnlyMode = false; // ... let isReadOnlyMode: booleanisReadOnlyMode = true;}Open in playground

This is useful due to lexical scope.

Fun fact!

Lexical used to be called "Outline", but at some point, it was renamed to "Lexical", which is a reference to JavaScript lexical scope.

As Dominic Gannaway (the brain behind this approach!) told me:

"This is exactly why we called the project Lexical. It has double meaning. Lexical's main workflow is via lexical scopes, but Lexical also has meanings around language and words."

If you're unfamiliar with this concept, you should learn about it before you read on.

Specifically, Lexical is taking advantage of module scope, which is the scope at which the variable is being declared. This means that we have access to the variable from anywhere in the module.

Here's something interesting though. If you've used Lexical before, you might have run into this error while trying to call a method like node.insertAfter() outside of an LexicalEditor.update() callback:

ts
// this is fineconst editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {
const node: LexicalNodenode.LexicalNode.insertAfter(nodeToInsert: LexicalNode, restoreSelection?: boolean): LexicalNode

Inserts a node after this LexicalNode (as the next sibling).

@paramnodeToInsert - The node to insert after this one.@paramrestoreSelection - Whether or not to attempt to resolve the selection to the appropriate place after the operation is complete.
insertAfter
(const paragraph: ParagraphNodeparagraph);
});// this failsconst node: LexicalNodenode.LexicalNode.insertAfter(nodeToInsert: LexicalNode, restoreSelection?: boolean): LexicalNode

Inserts a node after this LexicalNode (as the next sibling).

@paramnodeToInsert - The node to insert after this one.@paramrestoreSelection - Whether or not to attempt to resolve the selection to the appropriate place after the operation is complete.
insertAfter
(const paragraph: ParagraphNodeparagraph);
// error: "Cannot use method in read-only mode"
Open in playground

How does insertAfter know about the read-only mode state? insertAfter is not declared in the same file as isReadOnlyMode, so what's going on?

We're getting very close to the answers now.

Updates run synchronously

Take another look at updateEditor. It is synchronous, which means that we can be confident about the order of operations. Let me illustrate this:

ts
let let isReadOnlyMode: booleanisReadOnlyMode = true;export function function updateEditor(editor: LexicalEditor, updateFn: () => void): voidupdateEditor(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor, updateFn: () => voidupdateFn: () => void) { var console: Consoleconsole.Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(let isReadOnlyMode: booleanisReadOnlyMode); // > true
let isReadOnlyMode: booleanisReadOnlyMode = false; var console: Consoleconsole.Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(let isReadOnlyMode: falseisReadOnlyMode); // > false
let isReadOnlyMode: booleanisReadOnlyMode = true; var console: Consoleconsole.Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(let isReadOnlyMode: trueisReadOnlyMode); // > true
}
Open in playground

This is fairly simple, but important! The key takeaway here is that, by the time our update callback is called, isReadOnlyMode will be false, effectively signaling that the pending state is mutable at that time:

ts
let let isReadOnlyMode: booleanisReadOnlyMode = true;export function function updateEditor(editor: LexicalEditor, updateFn: () => void): voidupdateEditor(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor, updateFn: () => voidupdateFn: () => void) { let isReadOnlyMode: booleanisReadOnlyMode = false; updateFn: () => voidupdateFn(); // While this function runs, isReadOnlyMode is false. let isReadOnlyMode: booleanisReadOnlyMode = true;}Open in playground

Module scope can be accessed externally

Modules can expose values in their scope to the outside world through export statements. That's the point of modules after all!

ts
// @filename: module-one.tsconst const privateConst: "my constant"privateConst = "my constant";let let privateLet: stringprivateLet = "my variable";function function privateFunction(): voidprivateFunction() { // ...}export const const publicConst: "my public constant"publicConst = "my public constant";export let let publicLet: stringpublicLet = "my public variable";export function function publicFunction(): voidpublicFunction() { // ...}Open in playground

We can import public modules like this:

ts
// @filename: module-two.tsimport { const publicConst: "my public constant"publicConst, let publicLet: stringpublicLet, function publicFunction(): voidpublicFunction } from "./module-one";Open in playground

Values that are not exported cannot be imported from other modules (they are "private").

ts
// @filename: module-two.tsimport { privateConst, privateLet, privateFunction } from "./module-one";
Module '"./module-one"' declares 'privateFunction' locally, but it is not exported.
Module '"./module-one"' declares 'privateLet' locally, but it is not exported.
Module '"./module-one"' declares 'privateConst' locally, but it is not exported.
Open in playground

Let's look at the isReadOnlyMode variable again. It's not exported, we cannot use it directly from other modules:

ts
// @filename: LexicalUpdates.tslet let isReadOnlyMode: booleanisReadOnlyMode = true; // no "export" keyword!export function function updateEditor(): voidupdateEditor() {}// @filename: a-module-somewhere.tsimport { isReadOnlyMode } from "./LexicalUpdates";
Module '"./LexicalUpdates"' declares 'isReadOnlyMode' locally, but it is not exported.
function function $mutateStateSomehow(): void$mutateStateSomehow() { if (import isReadOnlyModeisReadOnlyMode) { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Cannot use method in read-only mode");
}}
Open in playground

However, we can still create a function in our module that returns it. That's exactly what Lexical does:

ts
// @filename: LexicalUpdates.tslet let isReadOnlyMode: booleanisReadOnlyMode = true;export function function isCurrentlyReadOnlyMode(): booleanisCurrentlyReadOnlyMode(): boolean { return let isReadOnlyMode: booleanisReadOnlyMode;}// @filename: a-module-somewhere.tsimport { function isCurrentlyReadOnlyMode(): booleanisCurrentlyReadOnlyMode } from "./LexicalUpdates";function function $mutateStateSomehow(): void$mutateStateSomehow() { if (function isCurrentlyReadOnlyMode(): booleanisCurrentlyReadOnlyMode()) { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Cannot use method in read-only mode");
}}
Open in playground

Lexical goes a step further by directly exporting a function that performs this check and throws the error for us:

ts
// @filename: LexicalUpdates.tslet let isReadOnlyMode: booleanisReadOnlyMode = true;export function function errorOnReadOnly(): voiderrorOnReadOnly(): void { if (let isReadOnlyMode: booleanisReadOnlyMode) { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Cannot use method in read-only mode.");
}}// @filename: a-module-somewhere.tsimport { function errorOnReadOnly(): voiderrorOnReadOnly } from "./LexicalUpdates";function function $mutateStateSomehow(): void$mutateStateSomehow() { function errorOnReadOnly(): voiderrorOnReadOnly();}
Open in playground

Module scope + synchronous execution = win

It's time to connect the dots!

In the previous section, we created $mutateStateSomehow. When this function is called, it checks the value of isReadOnlyMode from the LexicalUpdates.ts module.

This function is intended to be called inside of updateEditor (normally through LexicalEditor.update()), like this:

ts
const editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {
function $mutateStateSomehow(): void$mutateStateSomehow();});
Open in playground

Remember, our update callback is executed synchronously, and right before isReadOnlyMode:

ts
let let isReadOnlyMode: booleanisReadOnlyMode = true;export function function updateEditor(editor: LexicalEditor, updateFn: () => void): voidupdateEditor(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor, updateFn: () => voidupdateFn: () => void) { let isReadOnlyMode: booleanisReadOnlyMode = false; updateFn: () => voidupdateFn(); // $mutateStateSomehow is executed at this point. let isReadOnlyMode: booleanisReadOnlyMode = true;}Open in playground

This means that, when $mutateStateSomehow is called, isReadOnlyMode will be false, and the error will not be thrown.


Now you should be able to see how Lexical cleverly takes advantage of these behaviors (synchronous execution and module scope).

It uses them to create a "global state" of sorts. In this case, to track whether the editor is in read-only mode at some point in time. Functions that wish to mutate the state can perform this check through this technique.

We can answer the last question now:

What are these $ methods?

The $ prefix is a convention used with Lexical to denote functions that depend on the LexicalUpdate.ts lexical scope and, therefore, can only be called inside of the LexicalEditor.update() (or EditorState.read()!) callback.

There are many $ functions in the Lexical packages. For example, here are the ones exported by the lexical package itself:

ts
import { $
  • $addUpdateTag
  • $applyNodeReplacement
  • $copyNode
  • $createLineBreakNode
  • $createNodeSelection
  • $createParagraphNode
  • $createPoint
  • $createRangeSelection
  • $createTabNode
  • $createTextNode
  • $getAdjacentNode
  • $getNearestNodeFromDOMNode
  • $getNearestRootOrShadowRoot
  • $getNodeByKey
  • $getPreviousSelection
  • $getRoot
  • $getSelection
  • $getTextContent
  • $hasAncestor
  • $hasUpdateTag
  • $insertNodes
  • $INTERNAL_isPointSelection
  • $isBlockElementNode
  • $isDecoratorNode
  • $isElementNode
  • $isInlineElementOrDecoratorNode
  • $isLeafNode
  • $isLineBreakNode
  • $isNodeSelection
  • $isParagraphNode
  • $isRangeSelection
  • $isRootNode
  • $isRootOrShadowRoot
  • $isTabNode
  • $isTextNode
  • $nodesOfType
  • $normalizeSelection__EXPERIMENTAL
  • $parseSerializedNode
  • $selectAll
  • $setCompositionKey
  • $setSelection
  • $splitNode
} from "lexical";
Open in playground

When building something with Lexical, it's also useful to use this convention for your own functions. A good rule of thumb is that, if your function is calling a $ function, it should also be prefixed with $.

Active editor and state

Lexical uses the same technique to track which editors and editor states are currently "active" (which means they are the target of the current operation).

ts
let let isReadOnlyMode: booleanisReadOnlyMode = true;let let activeEditorState: EditorState | nullactiveEditorState: null | class EditorStateEditorState = null;let let activeEditor: LexicalEditor | nullactiveEditor: null | class LexicalEditorLexicalEditor = null;export function function isCurrentlyReadOnlyMode(): booleanisCurrentlyReadOnlyMode(): boolean { return let isReadOnlyMode: booleanisReadOnlyMode;}export function function getActiveEditorState(): EditorStategetActiveEditorState(): class EditorStateEditorState { if (let activeEditorState: EditorState | nullactiveEditorState === null) { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
(
"Can only be used in the callback of" + "editor.update() or editorState.read()" ); } return let activeEditorState: EditorStateactiveEditorState;}export function function getActiveEditor(): LexicalEditorgetActiveEditor(): class LexicalEditorLexicalEditor { if (let activeEditor: LexicalEditor | nullactiveEditor === null) { throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Can only be used in the callback of editor.update()");
} return let activeEditor: LexicalEditoractiveEditor;}export function function updateEditor(editor: LexicalEditor, updateFn: () => void): voidupdateEditor(editor: LexicalEditoreditor: class LexicalEditorLexicalEditor, updateFn: () => voidupdateFn: () => void) { const const currentEditorState: EditorStatecurrentEditorState = editor: LexicalEditoreditor.LexicalEditor._editorState: EditorState
@internal
_editorState
;
editor: LexicalEditoreditor.LexicalEditor._pendingEditorState: EditorState | null
@internal
_pendingEditorState
= function cloneEditorState(current: EditorState): EditorStatecloneEditorState(const currentEditorState: EditorStatecurrentEditorState);
// Set lexically scoped variables. let isReadOnlyMode: booleanisReadOnlyMode = false; let activeEditor: LexicalEditor | nullactiveEditor = editor: LexicalEditoreditor; let activeEditorState: EditorState | nullactiveEditorState = editor: LexicalEditoreditor.LexicalEditor._pendingEditorState: EditorState
@internal
_pendingEditorState
;
// Execute the update callback. updateFn: () => voidupdateFn(); // Reset lexically scoped variables. let isReadOnlyMode: booleanisReadOnlyMode = true; let activeEditor: LexicalEditor | nullactiveEditor = null; let activeEditorState: EditorState | nullactiveEditorState = null; // Commit pending updates. function commitPendingUpdates(editor: LexicalEditor): voidcommitPendingUpdates(editor: LexicalEditoreditor);}
Open in playground

Which gives the answers to the remaining questions:

How is the resulting state persisted?

The pending state is stored in activeEditorState. This state will be mutable during the LexicalEditor.update() callback, so it can be modified by it. Later, commitPendingUpdates will take care of replacing the current state with the pending one, effectively "persisting" it.

How does $getRoot() know which editor to get the root node from?

The editor instance is stored in activeEditor when LexicalEditor.update() is called. This allows functions like $getRoot() to access it (through getActiveEditor()).

EditorState.read()

Sometimes you just want to read a state, without mutating it.

Since we still depend on the module scope, we need a readEditorState function that sets up the lexical scope for us, similar to updateEditor. It looks like this:

ts
export function function readEditorState<V>(editorState: EditorState, callbackFn: () => V): VreadEditorState<function (type parameter) V in readEditorState<V>(editorState: EditorState, callbackFn: () => V): VV>( editorState: EditorStateeditorState: class EditorStateEditorState, callbackFn: () => VcallbackFn: () => function (type parameter) V in readEditorState<V>(editorState: EditorState, callbackFn: () => V): VV): function (type parameter) V in readEditorState<V>(editorState: EditorState, callbackFn: () => V): VV { const const previousActiveEditorState: EditorState | nullpreviousActiveEditorState = let activeEditorState: EditorState | nullactiveEditorState; const const previousReadOnlyMode: booleanpreviousReadOnlyMode = let isReadOnlyMode: booleanisReadOnlyMode; const const previousActiveEditor: LexicalEditor | nullpreviousActiveEditor = let activeEditor: LexicalEditor | nullactiveEditor; let activeEditorState: EditorState | nullactiveEditorState = editorState: EditorStateeditorState; let isReadOnlyMode: booleanisReadOnlyMode = true; let activeEditor: LexicalEditor | nullactiveEditor = null; const const result: Vresult = callbackFn: () => VcallbackFn(); let activeEditorState: EditorState | nullactiveEditorState = const previousActiveEditorState: EditorState | nullpreviousActiveEditorState; let isReadOnlyMode: booleanisReadOnlyMode = const previousReadOnlyMode: booleanpreviousReadOnlyMode; let activeEditor: LexicalEditor | nullactiveEditor = const previousActiveEditor: LexicalEditor | nullpreviousActiveEditor; return const result: Vresult;}Open in playground

I simplified this snippet to make it easier to follow, the original function uses a different pattern. I wrote about it:

Try, return, finally: a curious JavaScript pattern

Commit scheduling and batching

The commit phase of an update (commitPendingUpdates) is scheduled in a microtask by default, meaning that it will only happen after all other synchronous code has finished executing.

If you execute multiple updates synchronously in a row, the resulting state will only be committed once (at the end of the current tick).

This also happens with nested updates, which are updates that have been created during the execution of another update, or in other words, a LexicalEditor.update() call from the callback of another LexicalEditor.update() call.

Here's an example:

ts
const editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {}); // A
// > update A is executedconst editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {}); // B
// > update B is executedconst editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {}); // C
// > update C is executed// (any other synchronous code is executed...)// --- synchronous code finishes executing ---// > updates A, B and C are committed
Open in playground

This is possible due to the architecture of updates that we've learned about in the previous sections.

When multiple updates happen synchronously, they will all be updating the same pending state in a chain. Conceptually, the output of the first update will be the input of the second update, and so on.

Only once all updates are done, the pending state will be committed, and the editor state will be replaced with it. This is an important performance optimization since reconciliation can be an expensive process.

Discrete updates

While commit scheduling and batching are normally what we want, they can sometimes get in the way.

Consider this example: you're trying to manipulate an editor state in a server context and then persist it in a database.

ts
const editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(() => {
// manipulate the state...});function saveToDatabase(editorState: SerializedEditorState): voidsaveToDatabase(const editor: LexicalEditoreditor.LexicalEditor.getEditorState(): EditorState

Gets the active editor state.

@returnsThe editor state
getEditorState
().EditorState.toJSON(): SerializedEditorStatetoJSON());
Open in playground

This code will not work as expected, because the saveToDatabase call will happen before the state has been committed. The state that will be saved will be the same one that existed before the update.

Fortunately, the discrete: true option for LexicalEditor.update forces an update to be immediately committed.

ts
const editor: LexicalEditoreditor.LexicalEditor.update(updateFn: () => void, options?: EditorUpdateOptions): void

Executes an update to the editor state. The updateFn callback is the ONLY place where Lexical editor state can be safely mutated.

@paramupdateFn - A function that has access to writable editor state.@paramoptions - A bag of options to control the behavior of the update.@paramoptions.onUpdate - A function to run once the update is complete. Useful for synchronizing updates in some cases.@paramoptions.skipTransforms - Setting this to true will suppress all node transforms for this update cycle.@paramoptions.tag - A tag to identify this update, in an update listener, for instance. Some tags are reserved by the core and control update behavior in different ways.@paramoptions.discrete - If true, prevents this update from being batched, forcing it to run synchronously.
update
(
() => { // manipulate the state... }, { discrete?: true | undefineddiscrete: true });function saveToDatabase(editorState: SerializedEditorState): voidsaveToDatabase(const editor: LexicalEditoreditor.LexicalEditor.getEditorState(): EditorState

Gets the active editor state.

@returnsThe editor state
getEditorState
().EditorState.toJSON(): SerializedEditorStatetoJSON());
Open in playground

Now it works as it should!

Conclusion

Understanding how updates work in Lexical has been very useful in my work.

When I first started building with Lexical, things like the $ function prefix seemed scary and opaque at first. Not being certain about when or how updates are applied left me in the dark.

Once I understood, I was able to use this knowledge to my advantage. And when bugs arose, finding and fixing the root cause became much easier.

I hope this article has helped you understand updates too. Stay tuned for more articles about Lexical!


Bonus! Here's the feedback I received from a couple of maintainers of Lexical on this article:

  • "It's a brilliant article." - Dominic Gannaway
  • "This is really, really cool Dani! Thanks for putting this together! (I love the interactive examples as well). From my side, this reads very accurately fine." - John Flockton

Keep up with my stuff. Zero spam.