Building a rich text editor in React with SlateJS
Building an awesome editor for your React-based web application is by no means easy. But with SlateJS things get much easier. Even with the help of Slate, building a full-featured editor is way more work than we can cover in one blog post, so this post will give you the big picture and subsequent posts will dive into the dirty details.
Note: this post is based on a recent talk at the JavaScript NYC meetup. You can watch the video here: https://www.youtube.com/watch?v=wLjx67aNEMI
SlateJS
We’re building Kitemaker, a new, fast, and highly collaborative alternative to issue trackers like Jira, Trello, and Clubhouse. We’re big believers in remote work, and in particular working asynchronously so that team members get large, uninterrupted blocks of time to get work done. A key to supporting this type of work is having a really great editor so that teams are inspired to collaborate together on issues and ensure alignment. And we think we’ve got the best editor around:
Markdown shortcuts, code blocks with syntax highlighting, images, embedding designs from Figma, math expressions with LaTex, diagrams with MermaidJS, and, of course, emojis ♥️. All done entirely with Slate.
So why did we choose to go with Slate in the first place? It’s definitely not the only editor framework out there. For us, the things that pushed us towards Slate were:
- It’s un-opinionated about how your document is structured, and that gave us the flexibility we needed
- It doesn’t force any built-in toolbars or any other visuals upon you
- It’s built with React in mind and that makes a huge difference when rendering complex documents
- It lays the foundation for collaborative editing which is something we believe is important for Kitemaker
- We really like the philosophy behind it
- It has a thriving community, particularly on Slack
How Slate represents documents
One of the best parts of Slate is how it holds very few opinions about how documents are structured. It has only a few concepts:
- Editor — the top level container of your document
- Block level elements — the domain-specific “chunks” that make up your documents, like paragraphs, code blocks and lists. These are the things you can draw horizontal lines between in your document
- Inline elements — special elements that flow inline with the text of your document, like links
- Text nodes — these contain the actual text in the document
- Marks — annotations placed on the text, such as marking text as bold or italic
Astute readers will notice that this is structured very similar to the DOM, and text, block and inlines in Slate behave very much like their counterparts in the DOM.
Here’s an annotated screenshot of a Slate editor to explain these concepts visually:
Slate uses a very simple JSON format for representing documents, and the document above would look like this in Slate’s representation:
[
{
"type": "paragraph",
"children": [
{
"text": "Text with a link "
},
{
"type": "link",
"url": "https://kitemaker.co",
"children": [
{
"text": "https://kitemaker.co"
}
]
},
{
"text": " here"
}
]
},
{
"type": "paragraph",
"children": [
{
"text": "Text with "
},
{
"text": "bold",
"bold": true
},
{
"text": " and "
},
{
"text": "italic",
"italic": true
},
{
"text": " here"
}
]
}
]
As we said before, Slate is really un-opinionated about how documents are structured. In the JSON blob above, the only thing Slate cares about is that it gets an array of block elements with a children
property and that those children are either other block elements, or a mixture of text nodes and inline elements. That's it. Slate doesn't care about the type
, url
or bold
properties and it doesn't care about how these various nodes will be rendered. This makes it really flexible and powerful to work with.
Hello world editor
Enough background. Let’s look at some code! Let’s see what a simple editor component looks like using Slate:
function MyEditor() {
const editor = useMemo(() => withReact(createEditor()), []);
const [value, setValue] = React.useState<Node[]>([
{
children: [{ text: 'Testing' }],
},
]);
return (
<Slate editor={editor} value={value} onChange={(v) => setValue(v)}>
<Editable />
</Slate>
);
}
And that’s it. That’s an entire working component with Slate!
- We use
createEditor()
to make ourselves an editor (don't worry aboutwithReact()
for now - that's a plugin, which we'll discuss below) - We make a simple array of nodes to store the document state(just like we looked at above)
- We instantiate a
<Slate>
component which serves as a context provider, providing the editor we created above to all of the components below it. This is really cool because you can, for example, add toolbars and other components below the<Slate>
component that can grab the editor and manipulate the document with it. We also wire up thevalue
andonChange
properties, similar to any input in React - We add an
<Editable>
component which is the actual editor the user interacts with on the screen
Boring. How do we extend things?
So while that previous example was trivial to write, we still only have an editor that works about as well as <TextArea>
. Not very exciting.
Luckily Slate provides mechanisms for making things a whole lot more interesting:
- Plugins: plugins allow us to override the core behavior of the editor. They’re not concerned with rendering, just with overriding what happens when (for example) text is inserted, or an image is dropped
- Event handlers: allow us to handle things like key presses, so we can add hotkeys or allow pressing ‘tab’ to indent a bulleted list
- Custom rendering with React: with Slate we can specify exactly how each and every node in our document should be rendered using React
Let’s take a quick look at each of these.
Plugins
Plugins are a deceptively simple, powerful concept in Slate. Plugins generally look something like this:
export function withMyPlugin(editor: ReactEditor) {
const { insertText, insertData, normalizeNode, isVoid, isInline } = editor;
// called whenever text is inserted into the document (e.g. when
// the user types something)
editor.insertText = (text) => {
// do something interesting!
insertText(text);
};
// called when the users pastes or drags things into the editor
editor.insertData = (data) => {
// do something interesting!
insertData(data);
};
// we'll dedicate a whole post to this one, but the gist is that it's used
// to enforce your own custom schema to the document JSON
editor.normalizeNode = (entry) => {
// do something interesting!
normalizeNode(entry);
};
// tells slate that certain nodes don't have any text content (they're _void_)
// super handy for stuff like images and diagrams
editor.isVoid = (element) => {
if (element.type === 'image') {
return true;
}
return isVoid(element);
};
// tells slate that certain nodes are inline and should flow with text, like
// the link in our example above
editor.isInline = (element) => {
if (element.type === 'link') {
return true;
}
return isInline(element);
};
return editor;
}
By convention, their names start with with
. They take a Slate editor, override whatever functions they need to override and return the modified editor back. Very often, they handle a few cases in some of these functions and fall back to the default behavior for the rest. Spoiler alert: about 80% of adding functionality to a Slate editor is doing string matching in these plugin functions and then manipulate the document using Slate's rich API.
There are more functions you can override than the ones shown above, but these are the most common by far. You can read all about plugins in Slate’s documentation.
One of the most powerful parts of Slate plugins is that they can be composed together. Each plugin can look for the things it cares about and pass everything else along unmodified. Then you can compose multiple plugins together and get an even more powerful editor:
function MyEditor() {
const editor = useMemo(() => withReact(withDragAndDrop(withMarkdownShortcuts(withEmojis(withReact(createEditor())))), []);
...
}
Event handlers
Like many React input components, the <Editable>
component has a bunch of events to which you can listen. We don't have time to go through them all here, but we'll mention the one we use most often: onKeyDown()
By handling this event, we can do all sorts of powerful things in our editor, like adding hotkeys for example:
<Editable
onKeyDown={(e) => {
// let's make the current text bold if the user holds command and hits "b"
if (e.metaKey && e.key === 'b') {
e.preventDefault();
Editor.addMark(editor, 'bold', true);
}
}}
...
/>
We use key down events everywhere in Kitemaker:
- Nesting and un-nesting lists when tab is pressed
- Breaking out of lists and code blocks when enter (or in some cases backspace) is pressed
- Splitting blocks (e.g. headlines) when enter is pressed in the middle of a block
- And many many more
Custom rendering with React
Slate has no opinions on how your blocks and inlines should look on the screen. By default, it just shoves all blocks into plain <div>
elements and all inlines into plain <span>
elements, but that's pretty dull.
To override Slate’s default behavior, all we need to do is pass a function into the <Editable>
component's renderElement
property:
<Editable
renderElement={({ element, attributes, children }) => {
switch (element.type) {
case 'code':
return <pre {...attributes}>{children}</pre>;
case 'link':
return (
<a href={element.url} {...attributes}>
{children}
</a>
);
default:
return <div {...attributes}>{children}</div>;
}
}}
/>
All this code is doing is looking for the type
property on a node and picking a different rendering path based on that. Remember, as we said before, Slate doesn't care about these properties, only we do. So while the convention is to use type
to denote the type of a node, nothing is forcing you to do so. You can also add all sorts of other properties to your components that help with the rendering (like the url
property we saw on links above).
The things returned from renderElement
just need to be React components. What they look like and their complexity is entirely up to you. Here we're returning a simple <pre>
element to denote a code block, but nothing is stopping us from returning a full blown <Code>
component that supports syntax highlighting (like we do in Kitemaker).
There’s only one important thing to remember when implementing your own rendering — always spread the attributes
parameter as properties on the topmost component you're returning. If you don't, Slate won't be able to do its own internal bookkeeping and things will go very badly for you.
This has been a super quick introduction to custom rendering, so don’t worry if you don’t fully grasp it yet.
What’s tricky in Slate?
You’ve now seen some of the basics of Slate, so you’re ready to start experimenting. We thought we’d warn you a little about some of the pitfalls and tricky parts of working with Slate so you’ll see them coming and not get discouraged:
- Copying and pasting: copying and pasting on the web is a mess. We’ll dedicate a whole post to how we handle this in Kitemaker. Short version is that to test our own “paste logic”, we have made a somewhat complex document in a bunch of popular web editors Google docs, Notion, Dropbox Paper, Quip, etc.
- History: by default, Slate doesn’t support undo/redo. It does however come with a plugin called
useHistory()
that provides this functionality. However, we've found that this doesn't provide the exact user experience we're looking for and so we've had to extend it ourselves - Hovers/floating menus: dealing with things that pop up and need to be positioned correctly (like what you see in the Kitemaker editor above when “/” is typed to insert a block or “@” is typed to mention a user) can be pretty tricky
- Key handling: Kitemaker is a product with a ton of hotkeys for everything (we want our users to be able to use it without lifting their hands off the keys) but sometimes there have been some challenges with the key handling in Slate “fighting” with our hotkeys
- Big API surface: Slate’s API is quite extensive and we haven’t even begun to touch on it in this post. There are many, many functions for manipulating the document (adding nodes, removing nodes, splitting nodes, wrapping nodes, adding text, deleting words, etc.) and it’s not always totally obvious which API to use in which situation
- Some wonkiness with input methods like the Macbook Pro touch bar: we’ve had complaints from users using MBPs and also things like pen inputs for writing Japanese. There’s a PR open to fix some strange behaviors so hopefully it’ll be fixed soon 🤞
We’ll cover some of these advanced topics in our subsequent posts.
And a brief word of warning
While we’ve been very happy with Slate so far, there are a few warnings that any team embarking on building their own editor should be aware of:
- Like any open source project, progress on Slate comes in waves. While there was a huge rewrite last year and things moved really quickly, progress has now slowed down considerably. There are lots of open issues and open PRs. We hope we can do our share to improve this going forward, but the community could use more support
- After the rewrite last year, the documentation hasn’t quite gotten back to the standard it was before the rewrite. There’s a fair amount of stubbed-out documentation that lacks the details required by developers who are just getting started with the project. We’ve submitted some changes to improve this, but more work is needed
- Slate is not properly supported on Android. Fortunately, there was a Kickstarter project funded to fix this. Awesome! 🤙
More to come!
We hope this served as a nice high level introduction to Slate for you and gave you some of the information you need about whether or not to give Slate a try. There is way way way too much material to cover in a single post, but there will be a number of posts that follow that dig into some of the more complex topics.
Want to try out our editor so you can get a better sense of what Slate is capable of? Go sign up for Kitemaker and give it a try!
Thanks for reading! Did you find this article useful? If you want to see more material like this follow @ksimons and @KitemakerHQ on Twitter.