Mayowa Ojo Software developer with a knack for exploring new technology and writing about my experience.

Building a rich text editor with Lexical and React

6 min read 1901

React Lexical Framework

The term WYSIWYG is very commonly used in software to describe either a rich text editor or a system that gives you the ability to edit text in a rich format. With a WYSIWYG text editor, you essentially get to see what the end result will look like as you’re working on your document. This approach is beneficial, providing immediate feedback, unlike systems where you have to write some markup language like markdown.

WYSIWYG editors are very important features that you can find in many types of software, including content management systems, web builders, complex forms, note-taking tools, kanban boards, and more. As a matter of fact, this article was written in Dropbox Paper, which is a good example of a WYSIWYG editor.

In this article, we’ll use Lexical and React to build a simplified version of the Dropbox Paper editor. You can check out the source code for this article at this repo. Let’s get started!

What is Lexical?

Lexical is a dependency-free, extensible text editor framework that is being actively developed by Facebook at the time of writing. Lexical provides low-level APIs for developers to build their own editors with varying levels of complexity. It’s important to note that at the time of writing this article, Lexical is still in early development. However, in my opinion, the existing plugins are sufficient to build all kinds of great tools.

If you’re familiar with tools like CKEditor, Quill, and ProseMirror, then you’re aware that rich text editors have some common functionalities that are frequently required by developers. Lexical provides these features as a set of individual, modular packages that you can easily pull into your project based on your requirements, eliminating the need rewrite certain functionalities over and over again.

Lexical is not built for any specific platform. Rather, it is designed to be completely cross-platform and framework agnostic, implying that the underlying API can easily be ported to mobile or native desktop while still maintaining the same compatibility with the web version. It can also be seamlessly plugged into different frontend frameworks.

Features of Lexical

Lexical is a fast, reliable, lightweight, and accessible editor engine that aims to provide a great developer experience. Lexical is not opinionated about the appearance or styling of your editor’s UI. You can think of it as a headless, rich text editor that gives you the primitives to build anything you want. Its headless approach makes it highly extensible, allowing you to build new features or refine existing features to best suit your needs.

Some APIs that Lexical exposes include:

  • Plain text
  • Rich text
  • Selection
  • History
  • Clipboard
  • List
  • Table
  • Code
  • Link

Getting started with Lexical

Let’s explore some of the features of Lexical by building a simple clone of the Dropbox Paper editor. The editor consists of a floating toolbar at the bottom, and the whole document is the editor viewport.

We’ll use the React binding for Lexical in this example. To make it easily accessible, we’ll host it on Stackblitz with a link to the complete demo at the end of this article.

To get started, we’ll create a React project and install the necessary dependencies. You can choose whatever method you prefer to start a React project including Create React App, Stackblitz, or CodeSandbox:

$ npm install -S lexical @lexical/react @lexical/utils @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome clsx 

Next, we’ll import the necessary components to create an instance of the editor:

Editor.jsx
import React from 'react';
import clsx from 'clsx';
import {
  $getRoot,
  $getSelection,
  $isRangeSelection,
  FORMAT_TEXT_COMMAND,
} from 'lexical';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { mergeRegister } from '@lexical/utils';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

function onChange(state) {
  state.read(() => {
    const root = $getRoot();
    const selection = $getSelection();
    console.log(selection);
  });
}

export const Editor = () => {
  return (
    <div className='bg-white relative rounded-sm'>
      <LexicalComposer
        initialConfig={{
          theme: {
            paragraph: 'mb-1', // tailwind classes work!
          },
          onError(error) {
            throw error;
          },
        }}
      >
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="h-[450px] outline-none py-[15px] px-2.5 resize-none overflow-hidden text-ellipsis" />
          }
          placeholder={
            <div className="absolute top-[15px] left-[10px] pointer-events-none select-none">
              Now write something brilliant...
            </div>
          }
        />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
      </LexicalComposer>
    </div>
  )
}

We’ve imported the following components:

  • LexicalComposer: The editor root component that is also a context provider for the editor instance
  • LexicalRichTextPlugin: Exposes a set of common functionalities that enables rich text editing, including bold, italic, underline, strike-through, alignment text formatting, and copy and paste
  • LexicalOnChangePlugin: Executes a callback function whenever the editor state changes, letting you perform actions based on state change
  • HistoryPlugin: Provides editor history functionality, which exposes undo and redo commands

Styling the components

In the code block above, we created a simple instance of a Lexical editor and defined the initial config on LexicalComposer. In the config, we can customize certain behaviors of the editor. For example, we can customize the appearance of nodes by defining a theme object, which maps CSS class names to the editor. If you define a class name on the paragraph node, the styles that match that class name in your stylesheet will be applied to all corresponding nodes.

A much easier approach is to directly define inline styles using Tailwind CSS classes in our theme object, as we’ve done in the above code in Editor.jsx. This approach will also work on any component that you import; just add a className prop.

Building the Toolbar component

At this point, we have a simple working editor that we can type text into, but we have no way of controlling what formatting is applied. To address this, let’s build a floating toolbar similar to that in Dropbox Paper:

// Editor.jsx
//...
const Toolbar = () => {
  const [editor] = useLexicalComposerContext();
  const [isBold, setIsBold] = React.useState(false);

  const updateToolbar = React.useCallback(() => {
    const selection = $getSelection();
    if ($isRangeSelection(selection)) {
      setIsBold(selection.hasFormat('bold'));
    }
  }, [editor]);

  React.useEffect(() => {
    return mergeRegister(
      editor.registerUpdateListener(({ editorState }) => {
        editorState.read(() => {
          updateToolbar();
        });
      })
    );
  }, [updateToolbar, editor]);

  return (
    <div className="absolute z-20 bottom-0 left-1/2 transform -translate-x-1/2 min-w-52 h-10 px-2 py-2 bg-[#1b2733] mb-4 space-x-2 flex items-center">
      <button
        className={clsx(
          'px-1 hover:bg-gray-700 transition-colors duration-100 ease-in',
          isBold ? 'bg-gray-700' : 'bg-transparent'
        )}
        onClick={() => {
          editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
        }}
      >
        <FontAwesomeIcon
          icon="fa-solid fa-bold"
          className="text-white w-3.5 h-3.5"
        />
      </button>
      {/* ... */}
    </div>
  );
};

Let’s break down this code. We created a Toolbar component that returns a div element, that is positioned absolute at the bottom of the editor. We define a button element that represents a toolbar action, i.e., bold. We’ll also define other action buttons here.

The onClick event triggers a call to editor.dispatchCommand, which reacts to certain events like FORMAT_TEXT_COMMAND and updates the editor state accordingly. The RichTextPlugin already handles the FORMAT_TEXT_COMMAND internally, however, we can write our own custom commands using editor.registerCommandto explicitly handle more complex nodes.

The updateToolbar function runs every time the editor state changes. With this function, we can control the current state of each text format and show an indicator in the toolbar, either active or inactive.

In the useEffect callback, we actually register the listeners via registerUpdateListener. We just need to render the Toolbar component in the editor, as shown below:

//...
export const Editor = () => {
  return (
    <div className='bg-white relative rounded-sm'>
      <LexicalComposer
        initialConfig={{
          theme: {
            paragraph: 'mb-1', // tailwind classes work!
          },
          onError(error) {
            throw error;
          },
        }}
      >
        <Toolbar/>
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="h-[450px] outline-none py-[15px] px-2.5 resize-none overflow-hidden text-ellipsis" />
          }
          placeholder={
            <div className="absolute top-[15px] left-[10px] pointer-events-none select-none">
              Now write something brilliant...
            </div>
          }
        />
        <OnChangePlugin onChange={onChange} />
        <HistoryPlugin />
      </LexicalComposer>
    </div>
  )
}

Now, we can follow the same pattern to add more action buttons to our toolbar. We just need to duplicate the button element, then change the icon and onClick handler to dispatch the correct event. Let’s add an action button for italics text formatting:

// Editor.jsx
//...
<button
  className={clsx(
    'px-1 hover:bg-gray-700 transition-colors duration-100 ease-in',
    isItalic ? 'bg-gray-700' : 'bg-transparent'
  )}
  onClick={() => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
  }}
>
  <FontAwesomeIcon
    icon="fa-solid fa-italic"
    className="text-white w-3.5 h-3.5"
  /> 
</button>
//...

With that, we’re done! If you’ve followed along, you should have a working prototype of a simple WYSIWYG editor with minimal features that you can build on top of. The Lexical framework ships with a wide variety of prebuilt plugins that you can use to add different functionalities to your editor.

You can check out the complete code for the example in this article on Stackblitz. It includes more formatting features like underline, strikethrough, left/right/center/justify alignment, and more.

Alternatives to Lexical

There are several alternative tools to Lexical that can be used to build text editors and provide similar features.

One example is Draft.js which was also developed at Facebook. Unlike Lexical, the Draft.js framework is built exclusively for React. This implies that its underlying architecture is tightly coupled with the React framework.

While Draft.js is extensible and customizable, it does not offer cross-platform and framework-agnostic benefits like Lexical. Nevertheless, it still comes with a lot of the features needed to build any type of rich text editor. If cross-platform support is not a requirement for your project, I encourage you to explore Draft.js as an alternative to Lexical. It’s worth noting that Draft.js is now in maintenance mode and that new features will not be added.

Another good alternative to Lexical is Editor.js. This open source text editor framework produces JSON output as opposed to raw HTML, making it seamless to render the data on platforms different from the web.

The Editor.js core API provides a lot of the basic tools required to create a powerful rich text editor; however, it’s also extendable and pluggable by design, meaning you can easily import plugins for any feature you need in your editor. Here’s a quick start guide for creating your first text editor with Editor.js.

Conclusion

In this article, we’ve learned how to build a simple, rich text editor using the Lexical framework with React.



Keep in mind that because Lexical is still in early development at the time of writing, the APIs are bound to change over time as it’s being refined with each new version. However, there are a lot of interesting things you can build today with the current provided features and plugins. Thanks for reading, and be sure to leave a comment if you have any questions.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Mayowa Ojo Software developer with a knack for exploring new technology and writing about my experience.

2 Replies to “Building a rich text editor with Lexical and React”

Leave a Reply