If you're a software engineer, programmer or data scientist, you're well aware of the important role played by a reliable code editor. Whether you're creating a small script, experimenting with new ideas, or tackling complex projects, a code editor becomes your trusted companion when coding. And when it comes to my own note-taking application, Ballistic, the code editor takes center stage.
In this post, we'll embark on a fascinating journey into the implementation details of a code editor using React and CodeMirror. At the end, you’ll also find all the source code of the work mentioned in the post, so you can follow along where needed.
CodeMirror
As programmers, we embrace the principle of laziness, which means we prefer not to reinvent the wheel when it comes to implementing a fully functioning code editor. Instead, our first instinct is to explore existing solutions that we can leverage in our application. Fortunately, there are several notable options available:
- Ace Editor: Ace is a robust code editor written in JavaScript. It offers an array of features, including syntax highlighting, code folding, code completion, and support for multiple cursors.
- Monaco Editor: Powering Visual Studio Code, Monaco Editor provides a rich editing experience. Its feature set encompasses IntelliSense, debugging integration, Git integration, and more.
- React-CodeMirror: If you're specifically working with React, React-CodeMirror presents an excellent option. Acting as a wrapper component for CodeMirror, it seamlessly integrates CodeMirror's functionality into your React application. This allows you to leverage CodeMirror within your React components conveniently.
In this article, we'll focus on React-CodeMirror for several reasons. Since our development revolves around a React application, this library aligns perfectly with our needs. Additionally, React-CodeMirror is an open-source project licensed under the permissive MIT license. This means it has been thoroughly used and tested by numerous developers, often leading to the resolution of many small issues before we even embark on its implementation. The presence of a vibrant community surrounding an external library is always a crucial factor in our decision-making process, as it can provide valuable support and guidance when integrating the library into our project.
Project Setup
With the project context and our chosen options in mind, let's dive into setting up the development environment. We'll begin by creating a new React app and using Yarn as our dependency manager:
yarn create react-app react-code-editor --template typescript
Once the app is set up, we need to install the necessary CodeMirror packages. Open your terminal and run the following command:
yarn add @uiw/react-codemirror @uiw/codemirror-theme-github @uiw/codemirror-theme-darcula @codemirror/lang-markdown @codemirror/lang-python @codemirror/lang-javascript @codemirror/lang-cpp @codemirror/lang-html @codemirror/lang-json @codemirror/lang-java
To accelerate development and enhance our component implementation, we'll also incorporate Chakra UI, a comprehensive component library:
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
By leveraging Chakra UI, we gain access to a wide range of pre-built components that facilitate the creation of a visually appealing and user-friendly interface.
Creating a Basic Code Editor
Now that we have our React application set up and all the necessary dependencies installed, let's begin building our code editor by editing the src/App.tsx
file. Replace the existing code with the following:
import React, {useState} from 'react';
import {Center, ChakraProvider, Divider, Heading, VStack} from "@chakra-ui/react";
import {githubLight} from '@uiw/codemirror-theme-github';
import {python} from "@codemirror/lang-python";
import CodeMirror from '@uiw/react-codemirror';
function App() {
const [text, setText] = useState("print(\"Hello world!\")");
return (
<ChakraProvider>
<Center h={"100vh"}>
<VStack boxShadow={'md'} p={4} borderStyle={"solid"} borderWidth={1} rounded={"lg"}>
<Heading>Code Editor</Heading>
<Divider/>
<CodeMirror
value={text}
onChange={(newValue) => setText(newValue)}
theme={githubLight}
extensions={[python()]}
basicSetup={{autocompletion: true}}
minWidth={'500px'}
minHeight={'500px'}
/>
</VStack>
</Center>
</ChakraProvider>
);
}
export default App;
In this code snippet, we've created a basic version of our code editor using a few Chakra UI components. Specifically, we've used the CodeMirror
component from @uiw/react-codemirror
. By configuring its properties, we've specified that the code editor should interpret Python code and have a size of 500px by 500px. At this stage, we have a simple code editor that looks like this:
Note how inside the App
component, we use the useState
hook from React to create a state variable text
and a corresponding setter function setText
with an initial value of 'print("Hello world!")'
.
The component returns a JSX structure that includes Chakra UI components like Center
, VStack
, Heading
, and Divider
. The CodeMirror
component from @uiw/react-codemirror
is used to render the code editor itself.
The CodeMirror
component is configured with properties such as value
(bound to the text
state), onChange
(updating the text
state with the new value), theme
(using the GitHub Light theme), extensions
(using the Python language mode), basicSetup
(enabling autocompletion), and minWidth
and minHeight
(setting the dimensions of the code editor).
Adding More Supported Languages
To make our code editor more versatile and support multiple programming languages, we need to add language selection functionality. Let's start by creating an overview of the supported languages and their corresponding CodeMirror language support objects:
import { LanguageSupport } from '@codemirror/language';
import {markdown} from "@codemirror/lang-markdown";
import {javascript} from "@codemirror/lang-javascript";
import {cpp} from "@codemirror/lang-cpp";
import {html} from "@codemirror/lang-html";
import {json} from "@codemirror/lang-json";
import {java} from "@codemirror/lang-java";
const EXTENSIONS: { [key: string]: LanguageSupport[] } = {
markdown: [markdown()],
python: [python()],
javascript: [javascript()],
typescript: [javascript()],
cpp: [cpp()],
'c++': [cpp()],
html: [html()],
json: [json()],
java: [java()],
};
Now, we can enhance the user experience by providing a dropdown menu for language selection, and store the selected language in another state variable:
function App() {
const [language, setLanguage] = useState("python");
const [text, setText] = useState("print(\"Hello world!\")");
return (
<ChakraProvider>
<Center h={"100vh"}>
<VStack boxShadow={'md'} p={4} borderStyle={"solid"} borderWidth={1} rounded={"lg"}>
<HStack w={"100%"} justify={"space-between"}>
<Heading>Code Editor</Heading>
<Menu>
<MenuButton as={Button}>
{language}
</MenuButton>
<MenuList>
{Object.entries(EXTENSIONS).map(([language, _]) => (
<MenuItem onClick={() => setLanguage(language)}>{language}</MenuItem>
))}
</MenuList>
</Menu>
</HStack>
<Divider/>
<CodeMirror
value={text}
onChange={(newValue) => setText(newValue)}
theme={githubLight}
extensions={[EXTENSIONS[language]]}
basicSetup={{autocompletion: true}}
minWidth={'500px'}
minHeight={'500px'}
/>
</VStack>
</Center>
</ChakraProvider>
);
}
With the language selection dropdown menu in place, users can now choose the programming language they want to work with. The code editor will dynamically update based on the selected language. Here's how the updated application should look like:
Now, you have a more versatile code editor that supports multiple programming languages, providing a better user experience and expanding the range of use cases for your application.
Introducing Backend API Calls
In practice, it's common to save the text written by end-users to the backend application of your program. Currently, we are storing the written text in the React state variable text
. However, if the web page is refreshed, all changes made by the user will be lost.
Let's pause for a moment and take a closer look at the setText
function and its implications. Currently, every keystroke triggers a call to setText
. However, if this function also makes an API call to update the database and re-render the updated text in the <App />
component, it could result in an excessive number of API calls. This can negatively impact frontend performance and lead to a poor user experience.
To address this, it's beneficial to separate the API call from the immediate update shown to the user. One effective approach is to debounce the API call, ensuring it's executed after a short delay or when the user has finished typing. This reduces unnecessary API calls and improves performance, resulting in a smoother user experience.
Debounce the API calls
A debounce function is a useful tool that can delay the execution of a function until a certain amount of time has passed without the function being called again. It's particularly valuable in optimizing application performance by reducing unnecessary function calls. Let's introduce a new function called updateBackend
, which represents the backend call. Add the following code snippet to your App
component:
import { debounce } from 'ts-debounce';
// ...
const updateBackend = (newText: string) => {
console.log(`Updated backend with: ${newText}`);
};
const updateTextDebounced = useRef(debounce((newText: string) => updateBackend(newText), 1000 * 2));
useEffect(() => {
updateTextDebounced.current(text);
}, [text]);
Note that we're using an external library like ts-debounce to implement the debounce functionality. However, you can also create your own debounce function if you prefer. In the code, we use the useState
and useEffect
hooks to handle and debounce text input changes. Additionally, the useRef
hook ensures that the debounce function is created only once every two seconds when the text is being updated.
It's important to note that debounce functions have many other use cases as well. Here are a few examples:
- Search bar: When a user types a search term into a search bar, we might want to trigger a search function to display relevant results. However, triggering the search function on every keystroke can lead to unnecessary API calls and hinder application performance. By using a debounce function, we can wait for a certain duration after the user stops typing before triggering the search function, reducing API calls and improving overall performance.
- Resize events: If we have an element on the page that needs to be resized based on the window size, using a debounce function can delay the resize function until the user has finished resizing the window. This approach prevents the function from being called excessively during the resize event.
- Mousemove events: In scenarios where an element on the page needs to change based on the mouse's position, employing a debounce function can delay the update function until the user stops moving the mouse. This approach avoids triggering the function on every mousemove event, improving performance.
- Form validation: When a user fills out a form, it's common to validate the inputs and display any errors. By using a debounce function, we can delay the validation function until the user has finished typing, reducing unnecessary validation calls and enhancing performance.
Debounce functions are versatile and can be beneficial in any situation where you need to delay the execution of a function until a specific duration has passed without the function being called again. This approach reduces unnecessary function calls, leading to improved application performance.
The final and complete react component for your code editor now looks like this:
import React, {useEffect, useRef, useState} from 'react';
import {
Button,
Center,
ChakraProvider,
Divider,
Heading,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
VStack
} from "@chakra-ui/react";
import {githubLight} from '@uiw/codemirror-theme-github';
import {python} from "@codemirror/lang-python";
import CodeMirror from '@uiw/react-codemirror';
import {LanguageSupport} from '@codemirror/language';
import {markdown} from "@codemirror/lang-markdown";
import {javascript} from "@codemirror/lang-javascript";
import {cpp} from "@codemirror/lang-cpp";
import {html} from "@codemirror/lang-html";
import {json} from "@codemirror/lang-json";
import {java} from "@codemirror/lang-java";
import {debounce} from "ts-debounce";
const EXTENSIONS: { [key: string]: LanguageSupport } = {
markdown: markdown(),
python: python(),
javascript: javascript(),
typescript: javascript(),
cpp: cpp(),
'c++': cpp(),
html: html(),
json: json(),
java: java(),
};
function App() {
const [language, setLanguage] = useState("python");
const [text, setText] = useState("print(\"Hello world!\")");
const updateBackend = (newText: string) => {
console.log(`Updated backend with: ${newText}`);
};
const updateTextDebounced = useRef(debounce((newText: string) => updateBackend(newText), 1000 * 2));
useEffect(() => {
updateTextDebounced.current(text);
}, [text]);
return (
<ChakraProvider>
<Center h={"100vh"}>
<VStack boxShadow={'md'} p={4} borderStyle={"solid"} borderWidth={1} rounded={"lg"}>
<HStack w={"100%"} justify={"space-between"}>
<Heading>Code Editor</Heading>
<Menu>
<MenuButton as={Button}>
{language}
</MenuButton>
<MenuList>
{Object.entries(EXTENSIONS).map(([language, _]) => (
<MenuItem key={language} onClick={() => setLanguage(language)}>{language}</MenuItem>
))}
</MenuList>
</Menu>
</HStack>
<Divider/>
<CodeMirror
value={text}
onChange={(newValue) => setText(newValue)}
theme={githubLight}
extensions={[EXTENSIONS[language]]}
basicSetup={{autocompletion: true}}
minWidth={'500px'}
minHeight={'500px'}
/>
</VStack>
</Center>
</ChakraProvider>
);
}
export default App;
Conclusion
In conclusion, this blog post has guided you through the process of implementing a code editor in React using CodeMirror, a powerful JavaScript-based text editor. By following the step-by-step instructions, you've learned how to create a functional component that can render code or markdown text with syntax highlighting.
One crucial aspect of any text editor is the ability to update the text. However, directly calling an API endpoint on every text change can lead to an excessive number of API calls, impacting the frontend performance and resulting in a suboptimal user experience.
To address this issue, we've introduced the concept of a debounce function. By incorporating a debounce function, we can delay the execution of the API call until a short delay has passed or until the user has finished typing. This optimization reduces unnecessary API calls, improves performance, and ensures a smoother user experience.
The debounce function acts as a valuable tool in scenarios where you need to optimize performance by reducing the number of unnecessary function calls. Whether it's a code editor, a search bar, or any other interactive component, implementing debounce logic can significantly enhance the efficiency of your application.
By applying the knowledge and techniques shared in this blog post, you now have the skills to create a feature-rich code editor with an improved user experience. Remember to explore and adapt the debounce function to other areas of your application where performance optimizations are required.
Happy coding and enjoy building your next text editor with React and CodeMirror!
You can find all the source code used in this blog post here: https://github.com/GlennViroux/react-code-editor
Top comments (0)