Skip to main content
In Lexical, all state changes must happen within an update context. The editor.update() and editor.read() methods provide these contexts, along with access to special $ functions that can only be called within them.

The $ Function Convention

Functions prefixed with $ can only be called within:
  • editor.update(() => { ... })
  • editor.read(() => { ... })
  • editorState.read(() => { ... })
  • Node transforms
  • Command handlers
This is similar to React’s rules of hooks, but enforces synchronous context instead of call order.
import { $getRoot, $createParagraphNode } from 'lexical';

// ❌ This will throw an error
const root = $getRoot();

// ✅ This works
editor.update(() => {
  const root = $getRoot(); // OK - inside update context
});

Common $ Functions

import {
  $getRoot,
  $getSelection,
  $createParagraphNode,
  $createTextNode,
  $getNodeByKey,
  $isTextNode,
  $isParagraphNode,
} from 'lexical';

editor.update()

The primary method for making changes to the editor:
editor.update(() => {
  const root = $getRoot();
  const paragraph = $createParagraphNode();
  const text = $createTextNode('Hello, world!');
  
  paragraph.append(text);
  root.append(paragraph);
});

Update Options

type EditorUpdateOptions = {
  /** Callback to run after update completes */
  onUpdate?: () => void;
  
  /** Skip transforms for this update */
  skipTransforms?: true;
  
  /** Tag(s) to identify this update */
  tag?: UpdateTag | UpdateTag[];
  
  /** Force synchronous execution (no batching) */
  discrete?: true;
};
Example usage:
editor.update(
  () => {
    const root = $getRoot();
    root.clear();
  },
  {
    tag: 'clear-editor',
    onUpdate: () => {
      console.log('Editor cleared!');
    },
    discrete: true, // Don't batch with other updates
  }
);

Update Tags

Tags help identify updates in listeners:
import { $addUpdateTag } from 'lexical';

editor.update(() => {
  // Add tags dynamically within update
  $addUpdateTag('user-action');
  $addUpdateTag('formatting');
  
  // Your changes...
});

// Listen for tagged updates
editor.registerUpdateListener(({ tags }) => {
  if (tags.has('user-action')) {
    console.log('User-initiated change');
  }
});

Skipping Transforms

Sometimes you need to bypass transforms:
editor.update(
  () => {
    // Changes that shouldn't trigger transforms
    const node = $getRoot().getFirstChild();
    node?.markDirty();
  },
  {
    skipTransforms: true,
  }
);
Use skipTransforms sparingly. Transforms exist to maintain consistency.

editor.read()

For read-only access to the editor state:
const textContent = editor.read(() => {
  const root = $getRoot();
  return root.getTextContent();
});

console.log(textContent);

read() vs update()

// Read - no mutations allowed
editor.read(() => {
  const root = $getRoot();
  console.log(root.getTextContent());
  
  // ❌ This will throw an error
  root.clear();
});

// Update - mutations allowed
editor.update(() => {
  const root = $getRoot();
  
  // ✅ This works
  root.clear();
});

When to Use read()

Use read() when:
  • Getting text content
  • Computing derived values
  • Inspecting selection
  • Querying node structure
Benefits:
  • Clearer intent: Signals read-only operation
  • Safety: Prevents accidental mutations
  • Performance: May skip some overhead of update cycle

Update Batching

Multiple synchronous update() calls are automatically batched:
editor.update(() => {
  $getRoot().append($createParagraphNode());
});

editor.update(() => {
  $getRoot().append($createParagraphNode());
});

editor.update(() => {
  $getRoot().append($createParagraphNode());
});

// All three updates are batched into a single reconciliation

Discrete Updates

Force immediate execution without batching:
editor.update(
  () => {
    // This update won't be batched
    $getRoot().clear();
  },
  { discrete: true }
);

Nested Updates

You can nest updates:
editor.update(() => {
  const root = $getRoot();
  
  // Nested update
  editor.update(() => {
    root.clear();
  });
});
Nested updates are batched with the parent update. Use this pattern carefully.

The $onUpdate Helper

Schedule code to run after the update completes:
import { $onUpdate } from 'lexical';

editor.update(() => {
  const root = $getRoot();
  root.clear();
  
  $onUpdate(() => {
    console.log('Update finished!');
  });
});

Update Lifecycle

Here’s what happens during an update:

Step-by-Step

  1. Clone State: Create a writable copy of current state
  2. Run Callback: Execute your update function
  3. Apply Mutations: Process all node changes
  4. Run Transforms: Execute registered transforms
  5. Normalize: Merge adjacent text nodes, clean up
  6. Freeze State: Make new state immutable
  7. Reconcile: Calculate and apply DOM changes
  8. Notify: Trigger update listeners
  9. Callbacks: Run onUpdate functions

Reading Current State in Update

Within an update, you see the pending state:
editor.update(() => {
  const root = $getRoot();
  root.append($createParagraphNode());
  
  // This reflects the pending state (with new paragraph)
  console.log(root.getChildrenSize());
  
  // But transforms haven't run yet!
});

Getting Reconciled State

To get the latest reconciled state within an update:
editor.update(() => {
  // Make changes...
  $getRoot().clear();
  
  // Read reconciled state
  editor.getEditorState().read(() => {
    // This is the previous reconciled state
    const root = $getRoot();
  });
});
Calling editor.read() inside editor.update() flushes pending updates first. This can cause unexpected behavior.

Common Patterns

Clear Editor

editor.update(() => {
  const root = $getRoot();
  root.clear();
});

Replace Content

editor.update(() => {
  const root = $getRoot();
  root.clear();
  
  const paragraph = $createParagraphNode();
  paragraph.append($createTextNode('New content'));
  root.append(paragraph);
});

Update Based on Selection

import { $getSelection, $isRangeSelection } from 'lexical';

editor.update(() => {
  const selection = $getSelection();
  
  if ($isRangeSelection(selection)) {
    selection.insertText('Inserted at cursor');
  }
});

Conditional Updates

editor.update(() => {
  const root = $getRoot();
  
  if (root.getChildrenSize() === 0) {
    root.append($createParagraphNode());
  }
});

Batch Operations

editor.update(() => {
  const root = $getRoot();
  
  // All these operations are applied together
  const paragraphs = Array.from({ length: 10 }, () => {
    const p = $createParagraphNode();
    p.append($createTextNode('Paragraph'));
    return p;
  });
  
  root.append(...paragraphs);
});

Error Handling

try {
  editor.update(() => {
    // Your updates
    throw new Error('Something went wrong');
  });
} catch (error) {
  console.error('Update failed:', error);
}
Errors in updates are also passed to the error handler:
const editor = createEditor({
  onError: (error) => {
    console.error('Editor error:', error);
  },
});

Synchronous vs Asynchronous

Synchronous Updates

Updates run synchronously by default:
console.log('Before update');

editor.update(() => {
  console.log('During update');
  $getRoot().clear();
});

console.log('After update');

// Output:
// Before update
// During update
// After update

Async Functions in Updates

Don’t use async functions as update callbacks:
// ❌ Bad - don't do this
editor.update(async () => {
  const data = await fetchData();
  $getRoot().clear();
});

// ✅ Good - fetch first, then update
async function updateWithData() {
  const data = await fetchData();
  
  editor.update(() => {
    $getRoot().clear();
    // Use data...
  });
}

Performance Considerations

Minimize Updates

Batch multiple changes into a single update:
// ❌ Bad - multiple updates
for (let i = 0; i < 100; i++) {
  editor.update(() => {
    $getRoot().append($createParagraphNode());
  });
}

// ✅ Good - single update
editor.update(() => {
  const paragraphs = Array.from({ length: 100 }, () => 
    $createParagraphNode()
  );
  $getRoot().append(...paragraphs);
});

Use discrete Sparingly

Discrete updates bypass batching and can hurt performance:
// Use only when necessary
editor.update(() => {
  // Critical change that must apply immediately
}, { discrete: true });

Best Practices

Never mutate state outside of an update:
// ❌ Bad
const root = editor.getEditorState().read(() => $getRoot());
root.clear(); // Error!

// ✅ Good
editor.update(() => {
  $getRoot().clear();
});
Results from $ functions are only valid within the callback:
// ❌ Bad
let root;
editor.read(() => {
  root = $getRoot();
});
root.clear(); // Error!

// ✅ Good
editor.update(() => {
  const root = $getRoot();
  root.clear();
});
Prefer read() when you’re not making changes:
// ✅ Better - signals intent
const text = editor.read(() => {
  return $getRoot().getTextContent();
});

// Works but less clear
const text = editor.update(() => {
  return $getRoot().getTextContent();
});
Updates are synchronous but batched:
editor.update(() => { /* change 1 */ });
editor.update(() => { /* change 2 */ });
// Both changes are applied in a single reconciliation

// Use discrete to force immediate application
editor.update(() => { /* urgent change */ }, { discrete: true });
Use tags to identify update sources:
editor.update(
  () => {
    // User-initiated change
  },
  { tag: 'user-input' }
);

editor.registerUpdateListener(({ tags }) => {
  if (!tags.has('user-input')) {
    // Ignore programmatic changes
  }
});

Type Signatures

class LexicalEditor {
  update(
    updateFn: () => void,
    options?: EditorUpdateOptions
  ): void;
  
  read<T>(callbackFn: () => T): T;
}

type EditorUpdateOptions = {
  onUpdate?: () => void;
  skipTransforms?: true;
  tag?: UpdateTag | UpdateTag[];
  discrete?: true;
};

// Helper functions (only in update/read context)
function $getRoot(): RootNode;
function $getSelection(): BaseSelection | null;
function $getNodeByKey<T extends LexicalNode>(key: NodeKey): T | null;
function $setSelection(selection: BaseSelection | null): void;
function $onUpdate(callback: () => void): void;
function $addUpdateTag(tag: string): void;
  • Editor - The LexicalEditor instance
  • Editor State - Understanding state immutability
  • Nodes - Manipulating nodes in updates
  • Transforms - Automatic updates via transforms

Build docs developers (and LLMs) love