Draft.js, Redux, and Updating Your Editor in Real Time
The past several weeks I’ve been getting more and more into the weeds of React/Redux. For the uninitiated or for those who’ve been living under a rock, React is probably the most popular Javascript libraries for building user interfaces and Redux is currently one of the more popular Javascript “frameworks" for managing application state and architecture (loosely based on Facebook’s own Flux design paradigm). One the things I’ve been dabbling with involves Facebook’s Draft.js open source project - a rich text editing framework built under the React architecture.
In this post, I'm going to talk a bit about some of the issues I've encountered trying to wire up Draft.js with the Redux flow and my approach to overcoming these issues. I'm not going to be going over how React, Redux, or Draft.js works. I'm assuming you'll already have some basic understanding in each.
The naive solution
For those who've used Draft.js, it prescribes a particular methodology on how to use it and how it should be integrated within your app. A lot of it involves managing its own private state
lifecycle. This is fine and dandy if you are only managing the editor's content and state within the editor directly. However, you'll immediately run into issues if you're attempting to inject content to the editor from an external source.
For example, say you're working on a collaborative text editor similar to Google Docs, where you and someone else can type within the same editor and you both will be able to see each other's changes in real-time as you're editing. How do you update your current editor with their changes without losing your current position within the editor?
This is exactly the kind of problems that Redux is helping to solve! Through its single store and unidirectional data flow design paradigm, any text a person types into the editor would dispatch an action to update the store, and anyone listening in on the store changes will automatically pick up the latest changes. So in our case, when a user makes updates to the editor, we can save the current state of the editor into the store, and then other folks who are using the editor will pick up the changes to the store and rerender the editor with the changes!
Let's walk through the flow
Let's take a step back and look over some of the relevant pieces to Draft.js. If you take a look at the docs, the content and state of Draft.js are managed through a state object called EditorState. Many things are involved with creating the EditorState
. For now, let's only focus on the ContentState
because this is the main immutable record that represents the full state of the editor, including both the text content and the cursor info (i.e. SelectionState
).
Because we know that the ContentState
represents the actual text content for the editor, this is naturally what we want to persist to and from the store. So let's make some of the relevant code changes to make this happen. To do this, we're going to leverage the convertToRaw
and convertFromRaw
data conversion functions that are provided from Draft.js.
Whenever we save to the store, we convert the ContentState
to its raw immutable object, and when we re-render the editor, we convert it back to its ContentState
form.
Instead of using the local state
property, we're going to approach this the "Redux" way and expect the editorState
to be passed in as props
through Redux.
handleEditorChange = (editorState) => {
// Your Redux action
this.props.updateEditor({
content: convertToRaw(editorState.getCurrentContent())
})
}
render () {
return (
<Editor
editorState={this.props.editorState}
onChange={this.handleEditorChange} />
)
}
Whenever updates are made to the editor, an onChange
event will fire and this.handleEditorChange
will convert the new updated content to its raw form and dispatch this change to the store. Then after the store updates, it will send this content back and convert it to an EditorState
object to be passed back into the editor as props
.
EditorState.createWithContent(convertFromRaw(content))
After the EditorState
is passed back in as props
, the editor will re-render with the new state and cycle continues. You don't necessarily have to send a new EditorState every time, you can also update the existing EditorState of the component dynamically with the new content state. This is Redux 101, so I have't explained any new concepts here. It's as simple as that! Right?
Well.... yes and no. What you'll get is something like below
Update your SelectionState
If you read the docs closely, you'll see that we only took care of one aspect of updating ContentState. We only updated the content text, but we never updated the selection state of the editor. In the example above, we create a new EditorState
object every time the store gets updated. However, when we create the new EditorState, we do not provide an updated SelectionState
object. So when the Editor fires an onChange
event (which fires when we click back into the editor), the SelectionState's anchor and focus key is set to 0... every single time. Hence everything we type winds up at the top and is in reverse.
So how do we solve this? You may be thinking: Just save the SelectionState object too! Well you can definitely do that, and it'll definitely work if you're the only person who can influence this editor and store. But remember that other people may be trying to edit the same editor at the same time. So if you save the selection state, then that means someone else's selection state may be updated too. So one person's selection state will override someone else's and no one will have control over what their editor anymore!
This is where the local state
becomes useful again. Whenever the store gets updated and a re-render occurs, you can save a local editorState to keep track of your current selection state, and use the store's editorState to maintain the sync between the actual content. Like so:
handleEditorChange = (editorState) => {
// We need to continue updating the local state in order
// to get the latest selection position
this.setState({ editorState })
// Your Redux action
this.props.updateEditor({
content: convertToRaw(editorState.getCurrentContent())
})
}
render () {
return (
<Editor
// Updates the EditorState with the right selection index
editorState={EditorState.acceptSelection(this.props.editorState, this.state.editorState.getSelection())}
onChange={this.handleEditorChange} />
)
}
There's one more change we need to make. If you're currently out of focus from your editor and click back within it, it's possible for your selection state to set its anchor and focus position as 0 because technically the editor isn't in focus when you click back into it. To solve this, wrap your editor with another element and force a focus when you click back into it. Like the example below:
focus = () => {
this.refs.editor.focus()
}
handleEditorChange = (editorState) => {
// We need to continue updating the local state in order
// to get the latest selection position
this.setState({ editorState })
// Your Redux action
this.props.updateEditor({
content: convertToRaw(editorState.getCurrentContent())
})
}
render () {
return (
<div
onClick={this.focus} >
<Editor
// Updates the EditorState with the right selection index
editorState={EditorState.acceptSelection(this.props.editorState, this.state.editorState.getSelection())}
onChange={this.handleEditorChange}
ref='editor' />
</div>
)
}
**UPDATE (05/10/2017) **: One thing I failed to mention was that by updating the local state
, you are forcing another call to render
in the component. To bypass this additional render, instead of leveraging your local state
, you can set a static variable to keep track of the editorState
instead.
That's it!
You should now have a functional editor pulls its content from a store through Redux and updates the SelectionState correctly. However, I want to state I'm not 100% sure if this is the prescribed approach nor do I know if it's the correct one. However, this will be helpful for some folks who've may been encountering this issue because documentation is a little scarce. If you've found a better solution, please comment and let me know!