DEV Community

Cover image for How to Create a Code Editor in React and ChakraUI
Glenn Viroux
Glenn Viroux

Posted on

How to Create a Code Editor in React and ChakraUI

GIF showing the fully functional code editor

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

Image of basic code editor

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()],
};
Enter fullscreen mode Exit fullscreen mode

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>

    );
}
Enter fullscreen mode Exit fullscreen mode

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:

Image of basic code editor supporting multiple languages

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]);
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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;
Enter fullscreen mode Exit fullscreen mode

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)