Become a GraphQL expert

We're launching a brand new course. Pre-sale is now live.

View course

Overstacked

Tue Jun 11 2024

Integrating CodeMirror with React

A guide to building a responsive code editor with React and CodeMirror

cover image

CodeMirror is a code editor component for the web. However it is not written in React. If you're building a react app and want to use CodeMirror, here's a guide on how to integrate the two, and remain highly responsive.

Let's start by installing CodeMirror and its dependencies:

npm install codemirror

We're going to create a basic editor that accepts props such as onChange and onBlur. Our goal it to be able to integrate with react state and props.

Create a file called editor.ts and initialize the editor. Here you can expand on the editor configuration to include other plugins and themes.

import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'

const createEditor = (
  parentElement: Element,
  args: {
    onChange?: (value: string) => void
    onBlur?: () => void
    onFocus?: () => void
  }
) => {
  const { onChange, onBlur, onFocus } = args

  const state = EditorState.create({
    extensions: [
      // Handle changes
      EditorView.updateListener.of((update) => {
        if (update.docChanged && onChange) {
          const value = update.state.doc.toString()
          onChange(value)
        }
      }),
      EditorView.domEventHandlers({
        blur: onBlur,
        focus: onFocus,
      }),
    ],
  })

  const view = new EditorView({
    state,
    parent: parentElement,
  })

  return view
}

export default createEditor

Now let's create our React component which will render the editor.

import useCodeEditor from './useCodeEditor'

interface ICodeEditorViewProps {
  value: string
  onChange?: (value: string) => void
  onBlur?: () => void
  onFocus?: () => void
}

const CodeEditorView = (props: ICodeEditorViewProps) => {
  const { value, onChange, onBlur, onFocus } = props

  const { ref, editor } = useCodeEditor({
    value,
    onChange,
    onBlur,
    onFocus,
  })

  return <div ref={ref} />
}

export default CodeEditorView

Here we're using a custom hook to manage the editor state, this is where the magic happens. Let's build the hook useCodeEditor. This will connect the editor to the react component. I've added plenty of comments to explain what's happening.

import { EditorView } from 'codemirror'
import { useEffect, useRef, useState } from 'react'
import createEditor from './editor'
// We use an event emitter to decouple the editor from the
// react lifecycle. This means we do not need to destroy and
// recreate the editor any time the props change.
//
// Instead we can subscribe and unsubscribe to events
// which is less expensive and more performant.
import createEventEmitter from './editorEvents'

const useCodeEditor = (args: {
  value: string
  onChange?: (value: string) => void
  onBlur?: () => void
  onFocus?: () => void
}) => {
  const { value, onChange, onBlur, onFocus } = args

  // Define a ref which will be passed to the editor.
  // This is where the editor will be mounted.
  const ref = useRef<HTMLDivElement>(null)
  // Define a state to store the editor instance
  const [editor, setEditor] = useState<EditorView>()
  // Define a unique event emitter for each editor instance
  const emitterRef = useRef(createEventEmitter())

  // When the value changes we can call the editor's dispatch
  // method to reflect the new value in the editor.
  //
  // We can keep this simple and just replace the entire
  // editor value with the new value.
  useEffect(() => {
    if (editor && (value || value === '')) {
      const editorValue = editor.state.doc.toString()

      // Prevent infinite loop
      if (editorValue === value) {
        return
      }

      editor.dispatch({
        changes: {
          from: 0,
          to: editor.state.doc.length,
          insert: value,
        },
      })
    }
  }, [value, editor])

  // Initialize the editor once. Without event emitters
  // you would have to destroy and recreate the editor
  // any time the props change.
  useEffect(() => {
    const editorEvents = emitterRef.current

    const editorView = createEditor(ref.current!, {
      onChange: (value) => editorEvents.emit('change', value),
      onBlur: () => editorEvents.emit('blur'),
      onFocus: () => editorEvents.emit('focus'),
    })
    setEditor(editorView)

    return () => {
      editorView.destroy()
      setEditor(undefined)
    }
  }, [setEditor])

  // Subscribe to editor events when the props change
  // Ensuring we clean up and remove the listeners when
  // the component is unmounted.
  useEffect(() => {
    const editorEvents = emitterRef.current

    const changeListener = (value: string) => onChange?.(value)
    editorEvents.on('change', changeListener)

    const blurListener = () => onBlur?.()
    editorEvents.on('blur', blurListener)

    const focusListener = () => onFocus?.()
    editorEvents.on('focus', focusListener)

    return () => {
      editorEvents.off('change', changeListener)
      editorEvents.off('blur', blurListener)
      editorEvents.off('focus', focusListener)
    }
  }, [onChange, onBlur, onFocus])

  return { ref, editor }
}

export default useCodeEditor

Finally we need to create the event emitter. This is a simple file which outlines the available event types. We're using eventemitter3 for this.

import { EventEmitter } from 'eventemitter3'

// Define the types of events and their corresponding payloads
interface EditorEvents {
  change: (value: string) => void
  blur: () => void
  focus: () => void
}

const createEmitter = () => new EventEmitter<EditorEvents>()

export default createEmitter

By using an event emitter we can decouple the editor from the react lifecycle. This means we do not need to destroy the editor any time a callback prop changes. Instead we can subscribe and unsubscribe to events when props change.

This apparoch is far less expensive and more performant. The browser is simply doing less work.

Happy coding!


You can get more actionable ideas in my popular email newsletter. Each week, I share deep dives like this, plus the latest product updates. Join over 80,000 developers using my products and tools. Enter your email and don't miss an update.

You'll stay in the loop with my latest updates. Unsubscribe at any time.

© Copyright 2024 Overstacked. All rights reserved.

Created by Warren Day