Draft.js, Redux, and Updating Your Editor in Real Time

Javascript Mar 12, 2017

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

SelectionState 0

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!

Tags