How to roll your own slate js plugin system
Building a plugin system for Slate.js can transform your editor from a monolithic mess into a clean, maintainable architecture. In this article, I’ll show you how to implement a simple yet powerful plugin system that my clients have successfully used to scale their editor codebases.
The result? A codebase where features are self-contained, new team members can contribute faster, and you can easily compose different editor configurations for different use cases.
Let’s start by understanding why you need a plugin architecture in the first place.
Why do we need plugins?
A lot of editor codebases look something like this:
I call this a monolith. In a codebase like this - the modules mirror the interfaces that slate exposes. Every property of the interface (such as renderElement
or renderLeaf
, onKeyDown
or onMouseDown
) branches out into it’s own arbitrarily deep hierarchy of files and folders.
If you are a small team of 1 or 2 people who are experienced with slate-js, there’s really nothing wrong with this architecture.
But …
- if you are an organization with an engineering team - comprised of several engineers with varying seniority
- if your team is expected to work on several editing features in parallel
- if you want to keep their cognitive burden as low as possible - to make it less expensive to build and maintain the codebase and to onboard new members
- if your Editor functionality should be composable, so that you can bundle different functionality for different use-cases
… a monolithic codebase can be a cause of severe pain.
Frankly, the cognitive burden for developers will always be higher in text editors than in other parts of your front-end. They are unfortunately almost without exception - complex software.
That doesn’t mean that they have to be complicated codebases however.
Plugin systems have prevailed as a dominant architecture in text editors and IDEs for good reason. They soothe the pains described above, let’s have a look at how they do that.
Plugins are a better mental model
Consider this scenario: Your team is tasked with implementing text styling functionality (bold, italic, underline). In a monolithic architecture, they would need to:
- Update
renderLeaf
in the rendering module - Modify
onKeyDown
in the keyboard handling module - Add toolbar logic in the UI module
- Update normalization rules in the validation module
These modules are scattered across your codebase, and without deep Slate.js knowledge, they’re hard to find and modify safely.
With a plugin system, you create a single textStylingPlugin
that contains:
- All rendering logic for styled text
- Keyboard shortcuts (Ctrl+B, Ctrl+I, etc.)
- Toolbar button components
- Normalization rules to prevent invalid states
Real-world benefits I’ve seen:
- Faster development: New features take 2-3x less time to implement
- Easier testing: Each plugin can be tested in isolation
- Better onboarding: New developers can understand one feature at a time
- Reduced bugs: Changes are contained within plugin boundaries
- Flexible deployment: Enable/disable features per environment or user type
Enough about why plugins are cool ➡️ let’s see how you can add the goodness of plugins to your own editor without breaking a leg, an arm and your brain in the process.
A laughably simple plugin abstraction
A plugin system needn’t be complicated. The starting point I recommend to clients of mine is actually surprisingly simple.
First - let’s assume this signature for our plugins:
import { EditableProps } from 'slate-react';
import { Editor } from 'slate';
type Plugin = (editableProps: EditableProps, editor: Editor) => EditableProps;
As you can see - Plugin
is a function that takes EditableProps
and Editor
as arguments, and returns EditableProps
. This simple signature is sufficient for most use cases and provides a clean, composable interface.
And here’s the utility function that composes all of your plugins into a single EditableProps
, which you can then spread on your <Editable/>
component.
import { ReactEditor } from 'slate-react';
export const composeEditableProps = (
plugins: Plugin[],
editor: ReactEditor,
): EditableProps => {
let editableProps: EditableProps = {};
for (const plugin of plugins) {
editableProps = plugin(editableProps, editor);
}
return editableProps;
};
All composeEditableProps
does is loop over each plugin and feed the output of the last plugin to the next one.
Here are some simple example plugins to show it in use:
import { Element, Text, Transforms, Path } from 'slate';
import { DefaultElement } from 'slate-react';
/**
* This plugin defines default props such as autofocus and placeholder.
*/
const defaultPropsPlugin: Plugin = (editableProps) => ({
...editableProps,
autoFocus: true,
placeholder: 'Start typing...',
});
/**
* This plugin renders a header element and enforces text-only content
*/
const headerPlugin: Plugin = (editableProps, editor) => {
const { normalizeNode } = editor;
/**
* Override normalizeNode to enforce that header elements can only contain text
*/
editor.normalizeNode = (entry) => {
const [node, path] = entry;
if (Element.isElement(node) && node.type === 'header') {
// Remove any non-text children from headers
Transforms.unwrapNodes(editor, {
at: path,
match: (node, matchPath) =>
!Text.isText(node) && Path.isChild(matchPath, path),
});
}
// Call the original normalizeNode
normalizeNode(entry);
};
return {
...editableProps,
renderElement: (props) => {
if (props.element.type === 'header') {
return <h2 {...props.attributes}>{props.children}</h2>;
}
/**
* Chain to the previously declared renderElement method,
* or fall back to Slate's default element renderer
*/
return editableProps.renderElement?.(props) || <DefaultElement {...props} />;
},
};
};
```tsx
import { Slate, Editable } from 'slate-react';
import { useState } from 'react';
import { createEditor } from 'slate';
import { withReact } from 'slate-react';
/**
* Here we compose our plugins into a single EditableProps object
* and use it in our editor component
*/
const MyEditor = () => {
const [editor] = useState(() => withReact(createEditor()));
const [value, setValue] = useState([
{
type: 'paragraph',
children: [{ text: 'A line of text in a paragraph.' }],
},
]);
const editableProps = composeEditableProps([
defaultPropsPlugin,
headerPlugin,
], editor);
return (
<Slate editor={editor} onChange={setValue} value={value}>
<Editable {...editableProps} />
</Slate>
);
};
And that’s it. Above you can see two plugins, the header
plugin and the defaultProps
plugin and how they’re composed into EditableProps
.
What if I want my plugins to contain more than just editableProps?
Let’s say you have UI that is rendered outside the <Editable />
component (such as toolbar buttons to format your text) which you’d like to include in your plugin.
This is all possible, all you need to do is configure your type. For example, the plugin type could instead look like this:
type PluginProps = {
editableProps: EditableProps;
renderToolbar: () => JSX.Element;
}
type Plugin = (pluginProps: PluginProps, editor: Editor) => PluginProps;
We’ve now replaced the first argument and the return value of the Plugin
type with PluginProps
- which contains editableProps
but contains also an renderToolbar
prop, for rendering a toolbar.
The limits are your imagination when it comes to what functionality you can compose. It doesn’t have to be just slate specific.
Testing your plugins
One of the biggest advantages of a plugin system is how easy it becomes to test individual features. Here’s how you can test your plugins:
import { createEditor } from 'slate';
import { withReact } from 'slate-react';
import { headerPlugin } from './headerPlugin';
describe('headerPlugin', () => {
it('should render header elements correctly', () => {
const editor = withReact(createEditor());
const editableProps = headerPlugin({}, editor);
const mockProps = {
element: { type: 'header' },
attributes: {},
children: 'Test Header'
};
const result = editableProps.renderElement?.(mockProps);
expect(result).toBeDefined();
// Add more specific assertions here
});
it('should normalize header content to text only', () => {
const editor = withReact(createEditor());
const editableProps = headerPlugin({}, editor);
// Test normalization logic
// This is where you'd test that headers can't contain other elements
});
});
Why can’t I just use an existing plugin system?
Of course you can. There are great plugin libraries such as Plate that might work quite well for you. However their plugin system comes with strong opinions. Opinions that often collide with those of myself or my clients. So far - most projects I have worked on have opted to keep their editing functionality and also their plugin systems inside their own codebase.
Getting started
Ready to implement your own plugin system? Here’s your action plan:
- Start small: Begin with one or two simple plugins (like the
defaultPropsPlugin
andheaderPlugin
examples above) - Identify your features: Look at your current editor and identify distinct features that could become plugins
- Migrate incrementally: Don’t try to refactor everything at once. Move one feature at a time to the plugin system
- Test as you go: Write tests for each plugin to ensure they work correctly in isolation
- Document your patterns: Create clear guidelines for your team on how to structure and compose plugins
Next steps
- Explore advanced patterns: Once you’re comfortable with the basics, consider adding plugin dependencies, configuration options, and lifecycle hooks
- Build a plugin registry: Create a system to dynamically load and configure plugins based on your application’s needs
- Share with your team: Document your plugin system and create examples to help your team adopt the new architecture
Need help implementing this? I’ve helped dozens of teams migrate to plugin-based architectures. If you’re working on a complex editor and need guidance, feel free to reach out on Twitter or via email - I’d love to hear about your project and help you succeed.