Suggestions for using with React workflow

Is there a recommended or suggested workflow for using cm/next with React? I’ve been tinkering around with it a lot, and love the fact it makes bundling CM in with my Electron app a breeze without having to worry about externals etc with Webpack.

So to work well with React, I’m exploring the flow required with a state and a reducer, so that on each change, an action is dispatched keeping the reducer state in sync always.

The basic flow would be:

  1. Setup an Editor initially when a component first renders.
  2. Set the initial content of the editor
  3. Listen for change events, and dispatch that state change to the reducer, without directly updating the editor content at this point
  4. When the component re-renders based on that state change, for the content to be updated, with the cursor remaining in the correct place

My current implementation is something like the following:

class Editor extends React.Component {
	constructor(props) {
		super(props);

		this.codeMirror = React.createRef();
	}
	componentDidMount() {
		this.editor = new EditorView({
			state: EditorState.create({
				doc: this.props.content,
				extensions: [
					basicSetup,
					javascript(),
				],
			}),
			parent: this.codeMirror.current
		});
	}

	render() {
		if (this.editor) {
			this.editor.setState(EditorState.create({doc: this.props.content}));
		}

		return (
			<div ref={this.codeMirror} className="editor"></div>
		);
	}
}

For number 3 - detecting editor changes and dispatching an action, I’ve experimented with a few options:

  • Using EditorView.inputHandler to listen for changes, figure out the new state of the editor content, dispatch action to update state with new content, then return false to prevent other event handlers triggering - in effect - so it doesn’t update the actual editor via this.
  • Also looked into EditorView.domEventHandlers though I can’t see a way to simply work out the new state of the editor content without the editor itself being updated.
componentDidMount() {
	let listenerExtension = EditorView.inputHandler.of((view, from, to, text) => {
		let transaction = view.state.update({
			changes: {
				from,
				to,
				insert: text
			}
		});

		this.props.changeEvent(transaction.newDoc.toString()); // this props function dispatches an action

		return true;
	});

	this.editor = new EditorView({
		state: EditorState.create({
			doc: this.props.content,
			extensions: [
				basicSetup,
				javascript(),
				listenerExtension,
			],
		}),
		parent: this.codeMirror.current
	});
}

With both of the possible options above, one thing I’ve noticed is that if using backspace or delete for example, the handler isn’t triggered at all.

For number 4, re-rendering of the editor content when props have updated, in the render method, I need a way to just update the content, without replacing the entire state, as that will also reset extensions unless I explicitly state them again, and also preserving the cursor position too.

One possibility is to have something like:

render() {
	if (this.editor) {
		let transaction = this.editor.state.update({
			changes: {
				from: 0,
				to: this.editor.state.doc.toString().length,
				insert: this.props.content
			}
		});

		this.editor.dispatch(transaction);
	}

	return (
		<div ref={this.codeMirror} className="editor" onClick={this.props.mouseListener}></div>
	);
}

However, cursor position isn’t maintained, and it seems like styling isn’t applied to the changes either.

If anyone has a suggested way for this workflow, or any alternative ways of thinking about usage of CodeMirror/next with React, I’d be really keen to hear from you. Thank you.

I think the dispatch option is what you need (but I’m not experienced enough with React to see at a glance what you’re trying to do).

I got curious about how the new CodeMirror API would work with React, so I took a quick shot at this. I’d probably use hooks for this, both so we can use the built-in hooks and so our code is more reusable.

Definitely room for improvement here, but this demonstrates the main idea. We can use useCodeMirror to attach an editor to any React intrinsic element by passing the ref this returns. If we want to modify the editor state manually, we could do it in the reducer. We could also move the reducer to a parent component.

import { basicSetup, EditorState, EditorView } from "@codemirror/basic-setup";
import { Transaction, Text } from "@codemirror/state";
import { MutableRefObject, useEffect, useReducer, useRef } from "react";

interface Options {
  doc?: string | Text;
}

export default function useCodeMirror<T extends Element>(
  options: Options = {}
): MutableRefObject<T | null> {
  const element = useRef<T | null>(null);
  const view = useRef<EditorView | null>(null);

  const [state, dispatch] = useReducer(
    (state: EditorState, transaction: Transaction) =>
      state.update(transaction).state,
    undefined,
    () =>
      EditorState.create({
        doc: options.doc,
        extensions: basicSetup
      })
  );

  useEffect(() => {
    if (!element.current) return;

    if (!view.current) {
      view.current = new EditorView({
        state,
        dispatch,
        parent: element.current
      });
    } else if (view.current.state !== state) {
      // TODO: We probably want to dispatch transactions for perf,
      // rather than completely resetting state, but the main idea
      // is that we need to update the view to the latest state here
      view.current.setState(state);
    }

    return () => {
      if (!element.current) {
        view.current?.destroy();
        view.current = null;
      }
    };
  }, [state]);

  return element;
}

To simulate an app that eventually saves / restores the state to / from persistent storage, I’ve slightly changed @dabbott setup to serialize / deserialize the state on each transaction like so:

    (state: EditorState, transaction: Transaction) => {
        return EditorState.fromJSON(state.update(transaction).state.toJSON())
    },

Unfortunately, this breaks the Backspace button (Chrome on MacOS). Possibly a bug?

Edit: It appears that .config, .status and .values fields of the EditorState object are not serialized.