Lexical is an extensible JavaScript web text-editor framework with an emphasis on reliability, accessibility, and performance (quote from Lexical docs). Lexical doesn't define any specific interface for its plugins. We could create a plugin to extend LexicalEditor via CommandsTransformsNodes, or other APIs.

We are going through the process of creating an onBlurTextChangePlugin for Lexical editor. So when user clicks outside the editor, it saves and sends API calls as user defined.

The initial implementation

OnBlurTextChangePlugin is defined as:

import {
  useLexicalComposerContext,
} from '@lexical/react/LexicalComposerContext';
import { useEffect } from 'react';
import { $getRoot } from 'lexical';

const OnBlurTextChangePlugin = ({ onBlurTextChange }) => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    const removeListener = editor.registerRootListener((rootElement) => {
      if (rootElement !== null) {
        const handleBlur = () => {
          editor.getEditorState().read(() => {
            const text = $getRoot().getTextContent();
            onBlurTextChange?.(text);
          });
        };

        rootElement.addEventListener('blur', handleBlur);

        return () => {
          rootElement.removeEventListener('blur', handleBlur);
        };
      }
    });

    return removeListener;
  }, [editor, onBlurTextChange]);

  return null;
};

export default OnBlurTextChangePlugin;

And then add this to <LexicalComposer>

<LexicalComposer initialConfig={...}>
  <RichTextPlugin />
  <OnBlurTextChangePlugin onBlurTextChange={(text) => console.log('Blur text:', text)} />
</LexicalComposer>
  • registerRootListener lets you react to when the editor root mounts/unmounts, giving access to the actual DOM node.
  • Lexical does not expose a blur command, so this plugin bridges that gap via native DOM.
  • If you need the HTML instead of just plain text, you can also call $generateHtmlFromNodes() in the editorState.read() block.

Now we get a nice editor which saves the text when the user clicks outside the editor

Lexical editor with the onBlur plugin

Issues

Toolbar

One of the problems is that if a user clicks on the toolbar, it'd also save the typed text, which isn't what we want. We'd like the text to be saved when clicking outside the entire editor, including the toolbar. In order to do that, we need to exclude the blur action when mouse interacts with the toolbar.

First, we initializes the toolbar click flag:

let isClickingToolbar = false;

Then update the useEffect to return when toolbar is actually clicked

useEffect(() => {
  const toolbar = document.querySelector('.editor-toolbar');
  const handleMouseDown = () => {
    isClickingToolbar = true;
    setTimeout(() => {
      isClickingToolbar = false;
    }, 100);
  };

  toolbar?.addEventListener('mousedown', handleMouseDown);

  const removeListener = editor.registerRootListener((rootElement) => {
    if (rootElement !== null) {
      const handleBlur = () => {
        if (isClickingToolbar) return;

        editor.getEditorState().read(() => {
          const text = $getRoot().getTextContent();
          const serializedState = JSON.stringify(editor.getEditorState());
          onBlurTextChange?.({ text, editorState: serializedState });
        });
      };

      rootElement.addEventListener('blur', handleBlur);

      return () => {
        rootElement.removeEventListener('blur', handleBlur);
      };
    }
  });

  return () => {
    toolbar?.removeEventListener('mousedown', handleMouseDown);
    removeListener();
  };
}, [editor, onBlurTextChange]);

handleMouseDown is responsible to reset the flag to prevent potential memory leak. Also, make sure that toolbar has className editor-toolbar assigned.

  • mousedown fires before the blur, so you can mark that a toolbar click is happening.
  • You suppress the onBlurTextChange call if it happens as a result of clicking the toolbar.
  • setTimeout(() => isClickingToolbar = false) ensures that normal blur behavior resumes afterward.

Focus

When the user clicks outside the editor space, editing is complete, but the focus is still inside the editor, which leads to the page jump when user scrolls up/down the page.

Even though the blur event being handled, the editor's focus is not really lost, so scrolling later causes the browser to auto jump back to the still-focused but visually blurred ContentEditable. This happens espeically when using AutoFocusPlgin or interacting with toolbars/modals that don't truly steal focus. AutoFocusPlugin from @lexical/react/LexicalAutoFocusPlugin is a simple plugin that calls editor.focus() once when the editor mounts. However, it doesn’t manage focus dynamically—it doesn’t re-focus after blur or expose any method to release focus.

To fix this, you can manually blur the editor root element inside your OnBlurTextChangePlugin, but only after your logic runs, and with a small delay to avoid interfering with toolbar clicks or dropdowns.

First try with this:

const OnBlurTextChangePlugin = ({ onBlurTextChange }) => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    return editor.registerRootListener((rootElement) => {
      if (!rootElement) return;

      const handleBlur = (event) => {
        
        const relatedTarget = event.relatedTarget;
        if (relatedTarget?.closest('.toolbar') || relatedTarget?.closest('.suggestion-box')) {
          return;
        }

        editor.getEditorState().read(() => {
          const text = $getRoot().getTextContent();
          const editorStateJSON = JSON.stringify(editor.getEditorState());
          onBlurTextChange?.({ text, editorState: editorStateJSON });
        });

  
        setTimeout(() => {
          rootElement.blur(); 
        }, 0);
      };

      rootElement.addEventListener('blur', handleBlur, true);

      return () => {
        rootElement.removeEventListener('blur', handleBlur, true);
      };
    });
  }, [editor, onBlurTextChange]);

  return null;
};

setTimeout is to delay the blur to let selection changes finalize from toolbar. The true in addEventListener(..., true) ensures capturing phase, which is more reliable with nested focusable elements.

However, what happens with this is that once clicks outside the editor area, the component keeps reloading itself without releasing the focus, which means even after the blur() is called, the Lexical editor is re-focusing itself.

Fix strategies:

1) Remove AutoFocusPlugin after initial mount – AutoFocus should only run once, otherwise it may fighter the blur logic.

const [shouldAutoFocus, setShouldAutoFocus] = useState(true);

useEffect(() => {
  // After mount, prevent AutoFocusPlugin from refocusing on blur
  setTimeout(() => setShouldAutoFocus(false), 100);
}, []);

{shouldAutoFocus && <AutoFocusPlugin />}

2) Add a hasBlurred state to prevent repeated firing

  const hasBlurredRef = useRef(false);

  useEffect(() => {
    return editor.registerRootListener((rootElement) => {
      if (!rootElement) return;

      const handleBlur = (event) => {
        if (hasBlurredRef.current) return;

        hasBlurredRef.current = true;

        setTimeout(() => {
          if (document.activeElement !== rootElement) {
            editor.getEditorState().read(() => {
              const text = $getRoot().getTextContent();
              const editorStateJSON = JSON.stringify(editor.getEditorState());
              onBlurTextChange?.({ text, editorState: editorStateJSON });
            });

            rootElement.blur();
          }
        }, 0);
      };

      const handleFocus = () => {
        hasBlurredRef.current = false;
      };

      rootElement.addEventListener('blur', handleBlur, true);
      rootElement.addEventListener('focus', handleFocus, true);

      return () => {
        rootElement.removeEventListener('blur', handleBlur, true);
        rootElement.removeEventListener('focus', handleFocus, true);
      };
    });
  }, [editor, onBlurTextChange]);

setTimeout is to confirm there's no new focus

With these updates, now our editor can properly process the onBlur event via <OnBlurTextChangePlugin onBlurTextChange={onBlurTextChange} />