In this post I want to discuss one of the major challenges I faced in designing the post editor for DevBlog: stabilizing the post editor upon page refresh. As an introduction to the topic, let us talk about Draft JS first.
Draft JS is an awesome React library for building rich text editors. However, the library is extensive, and it often involves studying the source code for implementing features. Integrating Draft JS into your react app is not that complex, however; just include the below lines in a new file in your react project (if starting from scratch, run npx create-react-app your-app-name
in your terminal, and make sure to run npm install draft-js
afterwards):
// MyEditor.js
import React from "react";
import {Editor, EditorState} from 'draft-js';
class MyEditor extends React.Component {
constructor(props) {
super(props);
this.state = {editorState: EditorState.createEmpty()};
this.onChange = editorState => this.setState({editorState});
}
render() {
return (
<Editor
editorState={this.state.editorState}
onChange={this.onChange}
/>
);
}
}
export default MyEditor;
Then import the MyEditor component into a component that renders in a page:
// App.js
import MyEditor from "./editor/MyEditor";
export default function App() {
return (
<div className="App">
<MyEditor />
</div>
);
}
Find below a codesandbox that implements the above code snippets and renders the MyEditor component (the text editor) on a page.
What do you see? "I see a blank page". Well, click on the top-center of the code sandbox and start typing. What do you notice? Yes, you can write; that is not a text area, that is the Draft JS text editor.
As you may have guessed from the codesandbox, rendering the Editor component with its editorState
and onChange
props (refer to the render section of the first code snippet) is not enough for a rich-text editing experience. Some common workarounds for this involve wrapping the editor in several <div>'s to which we apply CSS classes to clearly delimit the text-editing area, and adding a section to include buttons for the functionality of the text editor (such as 'bold', 'italics', 'headers', etc).
This is where the Draft JS Wysiwyg text editor comes into play (Wysiwyg stands for "what you see is what you get"). It is a Draft JS editor that includes some styling and comes in with a panel of built-in buttons.
Before moving on with Draft JS Wysiwyg, let us explain the previous code snippets. In the first code snippet, we import Editor
and EditorState
from the draft-js library. Editor
is a React component (it is rendered), whereas EditorState
is an immutable object that encapsulates the state of the editor, including the editor's content, selection state ("whether it is focused, collapsed/non-collapsed, and the position of the cursor"), and undo/redo history. It is worth noting that "all changes to content and selection within the editor will create new EditorState
objects". In other words, every time you type in the editor triggers an onChange event which updates the editor's state by creating a new EditorState
object. From the first code snippet, we see that the Editor
component has an editorState prop whose value corresponds to the current state of the editor, and an onChange callback that updates the editor's state by passing the editorState variable as an argument. Last, when the editor is rendered for first time, a new instance of the EditorState
object is created via the createEmpty() static method and rendered to the page. You can find information about Editor
and EditorState
in the API basics section of draft-js' official documentation, and on this amazing guide for Draft-JS.
In the second code snippet, the MyEditor
component, which renders our draft-js Editor
component, is rendered. We can't leave behind React hooks, so let us quickly present the hooks version for the editor component:
// MyEditor.js
import React, { useState } from "react";
import { Editor, EditorState } from "draft-js";
function MyEditor() {
const [editorState, setEditorState] = useState( () =>
EditorState.createEmpty()
);
return (
<div>
<Editor
editorState={editorState}
onChange={setEditorState}
/>
</div>
);
}
export default MyEditor;
In the hooks version, we define an 'editorState' state variable using the useState hook, and we initialize this variable with a new instance of the EditorState object from the draft-js library. The editorState variable is then used in the props for the Editor component. You can find a working codesandbox for the above hooks version editor here.
Now, let us introduce the draft-js Wysiwyg editor (created by developer Jyoti Puri). It's worth noting that the Wysiwyg editor is built on top of the draft-js editor detailed in the previous section. To get started with the Wysiwyg editor, we include the below lines in a new file:
// WysiwygEditor.js
import React, { Component } from "react";
import { EditorState } from "draft-js";
import { Editor } from "react-draft-wysiwyg";
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
class WysiwygEditor extends Component {
constructor(props) {
super(props);
this.state = {
editorState: EditorState.createEmpty()
};
}
onEditorStateChange(editorState) {
this.setState({
editorState
});
}
render() {
const { editorState } = this.state;
return (
<Editor
editorState={editorState}
wrapperClassName=""
editorClassName=""
onEditorStateChange={(editorState) => this.onEditorStateChange(editorState) }
/>
);
}
}
export default WysiwygEditor;
Then we import the WysiwygEditor component in a component that renders in a page:
// App.js
import WysiwygEditor from "./editor/WysiwygEditor";
import "./styles.css";
export default function App() {
return (
<div className="App">
<WysiwygEditor />
</div>
);
}
Make sure to run npm install react-draft-wysiwyg
and npm install draft-js
for the code to work. The above code snippets render the below component:
Nice, isn't it? Scroll down on the sandbox and click on the top section of the blank space, then start typing. You can then play around with the buttons to add styling to your text. Now if you click on the sandbox's square-arrow button to preview the page in a new window, you'll notice that the editor occupies the full area of the page, which is not desirable (in most cases). To fix this, we'll make use of the built-in wrapperClassName and editorClassName props of the Editor component (whose values are empty strings currently; refer to the WysiwygEditor.js code snippet) plus some other props. The wrapper class wraps both, the buttons panel and the text editing area; the editor class wraps the text-editing area. We will add the following in the return section of the editor component:
// some code
<div className="text-editor">
<Editor
editorState={editorState}
wrapperClassName="wrapper-class"
editorClassName="editor-class"
toolbarClassName="toolbar-class"
onEditorStateChange={(editorState) => this.onEditorStateChange(editorState) }
/>
<div>
// more code
Then, in the main CSS file for our app we'll add the styles for each class name (in App.css file for a create-react-app):
.textEditor{
padding-top: 2rem;
padding-bottom: 2rem;
max-width: 736px;
margin-left: auto;
margin-right: auto;
}
.wrapper-class {
padding: 1rem;
border: 1px solid #ccc;
}
.editor-class {
background-color: lightgray;
padding: 1rem;
border: 1px solid #ccc;
}
.toolbar-class{
border: 1px solid lightgray;
}
And we get the below component:
The editor is now centralized and it does not take the full page area; the text-editing area has a background color, and there are borders for the editor and the buttons panel. Nice!! You can definitely play with the CSS for more styling. You can also refer to this awesome blog post for more detailed styling for the editor. A working codesandbox for this styled Wysiwyg editor can be here, and the hooks version codesandbox can be found here .
Blog posts in DevBlog (including this blog post) are written using a Draft JS Wysiwyg editor. So, after we write on the editor, we need a way to save the editor's contents to a database, that we can later query to display the blog post. The EditorState object of draft-js (which stores the contents of the editor) counts with a list of instance methods and static methods that can be used to manipulate or extract specific data from the editor's state. We will use a combination of these methods and other libraries to persist the editor's contents to a database. This is the roadmap that we'll follow to achieve this:
• editorState ------> HTML --------> database (API)
and
• database(API) -------> HTML -------> editorState
The relevant code for the roadmap will be included, but a full discussion of the roadmap is treated in my blog post Persisting Your Draft JS Wysiwyg Editor's Data To Your Back End . Let us consider the below lines of code to analyze how we can use the blog post data that we obtain from our API to load it into the editor:
// WysiwygDataPersistence.js
import React, { useState } from "react";
import { EditorState, ContentState, convertToRaw } from "draft-js";
import { Editor } from "react-draft-wysiwyg";
import { convertFromHTML } from "draft-convert";
import { useParams, useLocation } from "react-router-dom";
function WysiwygDataPersistence({posts}) {
const routeParams = useParams();
const location = useLocation();
const [title, setTitle] = useState("");
const [editorState, setEditorState] = useState(
() => {
if(location.pathname === "/posts/new") {
return EditorState.createEmpty();
} else if(routeParams.postId){
const currentPost = posts && posts.find( ({id}) => `${id}`=== routeParams.postId)
setTitle(currentPost.title)
return EditorState.createWithContent(convertFromHTML(currentPost.body))
}
}
);
// more code
}
In the above code snippet, the WysiwygDataPersistence component takes an incoming posts array as props. Then in the useState hook for the editorState variable, we use the location hook from react router v6 in a callback arrow function to determine whether the editor is being loaded to create a new post or to edit an existing post. If creating a new post, we simply use the static method createEmpty on the EditorState object from draft-js (the same method used in the two previous sections) to initialize a blank editor; if editing a post, we use the convertFromHTML function from draft-convert on the incoming HTML blog post data, then we call the createWithContent method to transform the results of the previous function into a immutable object used by the draft-js editor. Below is a codesandbox that contains a fully working prototype for a blog website that uses the draft-js wysiwyg editor along with an API for data persistence. In this prototype, you can read, create, edit, and delete posts, and the changes are saved. [Note: if you see a 502 error message in the codesandbox, refresh this page, and the codesandbox should load properly; if it still does not work, open the codesandbox, wait a minute for the scripts to run, and click on the refresh page button in the condesandbox's internal browser]
The full code for this post editor includes 90+ lines, and the code is fully explained in the previously mentioned post. The repository for the above codesandbox can be found here.
Now we are in the last section of this post. Our previous editor can successfully save the contents of a post and can be successfully initialized with the contents of a previously saved post. Now I'll invite to go to the previous code sandbox and click on the button for "Open preview in new window", then click on a post, click on "Edit" (this will open and load the editor with the post), and then click on the refresh page button. What do you see happens after refreshing the page? ... the editor crashes!!!! That is, the editor is not stable on page refresh. So, after many failed attempts, for many days, for many hours, I was finally able to come up with code that stabilizes the editor on page refresh. This will probably not the only way to achieve the desired result; however, it is the solution I came up with.
When starting the editor with some content, the content cannot be undefined or null, more specifically, the argument in this line of code EditorState.createWithContent(
argument
)
cannot be undefined, otherwise, the editor will break on startup. This line of code is executed when the editor is started with an already existing post and we want to edit the post. When the browser is refreshed while editing a post, the app reinitializes and, because we are getting our post data from our back end asynchronously, the post is not immediately available and is therefore undefined; hence, when the above line of code is executed, the editor crashes.
The solution: create a dummy state for the editor to start with until we get the data from our back end. Once the data arrives, replace the dummy state with the received data.
Let us see this solution in code:
// WysiwygStable.js
// ...code for importing the required modules
function WysiwygStable({posts}) {
const routeParams = useParams();
const location = useLocation();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [post, setPost] = useState({body: "<p>Loading content...</p>"})
const loadedPost = useCallback( () => {
return posts && posts.find( post => `${post.id}` === routeParams.postId)
},[posts, routeParams])
const loadedInitialEditorState = useCallback( () => (EditorState.createWithContent(convertFromHTML(post.body))
),[post])
const reinitializeState = useCallback ((argument) => {
const blocksFromHTML = htmlToDraft(argument?.body);
const { contentBlocks, entityMap} = blocksFromHTML
const contentState = ContentState.createFromBlockArray(contentBlocks, entityMap);
return EditorState.createWithContent(contentState)
},[])
useEffect( () => {
if (location.pathname !== "/posts/new" && posts) {
const content = loadedPost()
setTitle(content.title)
setPost(content)
setEditorState(reinitializeState(content))
}
},[location, loadedPost, reinitializeState, posts]
)
const [editorState, setEditorState] = useState(() => {
if(location.pathname === "/posts/new") {
return reinitializeState({body: ""})
} else {
return loadedInitialEditorState()
}
});
// more code
}
Before explaining the code, let's explain the big picture of the code: we want to define two variables related to a post; one variable will define a dummy post, and the other variable will hold the post that we want to edit. We will load the editor with the dummy post, then we will use an useEffect hook to replace the dummy post with the real post once we have received our post data from the back end.
Now let us go with the code. We set a post variable using the useState hook and initialize it with a dummy post object, {body: "<p>Loading content...</p>"}
. This is our dummy post variable. We also define a loadedPost variable using the useCallback hook, and set it equal to the post that we want to edit, coming from our back end. When the page is refreshed, the post variable is a post object, whereas the loadedPost variable is undefined until we get the post data from our back end. We then define a loadedInitialEditorState function with the useCallback hook which initializes the editor with our dummy post from the post variable. At this point, if we refresh the browser, the editor will show "Loading content...". We are now half way through the stabilizing process. What we need to do next is to replace this dummy post with the real post from our back end using the setEditorState variable from the useState hook present in the last block of the code snippet. To achieve this, we define a reinitializeState function with the useCallback hook. The job of this function is to take a post object with content in HTML, transform the HTML into data readable to the Draft JS editor through a series of functions and methods, and return a new EditorState object that is initialized using the transformed data. To make use of this reinitializeState function, we define an useEffect hook. In this hook, we check whether we are loading the editor to edit an existing post. If so, we call the reinitializeState function with an argument equal to the post object returned by the loadedPost variable (this is the post coming from our back end), and we set the editorState variable used by the editor equal to the EditorState object returned by the reinitializeState function; that is , we use this line of code:
setEditorState(reinitializeState(loadedPost()))
This will replace the contents of the editor with the data from the post to be edited. At this point, we have a stable editor on page refresh. You can even do a hard refresh on the browser (for Mac users, press shift + command + R), and the editor swiftly changes from "Loading content..." to your editing post without breaking.
You can find a codesandbox with the stable Wysiwyg editor here ; you can find the source code for both, the data persistence editor and the stable editor in this repository .
I hope you have found this post useful. For the moment, DevBlog does not include comments for blog posts, but I'll be adding this functionality soon.