CodeMirror 6 Best Practices
This document outlines the definitive best practices for developing with CodeMirror. We exclusively target CodeMirror 6 (CM6). Any reference to CodeMirror 5 (CM5) is for historical context only; CM5 must not be used in new development and should be actively migrated away from.
Code Organization and Structure
1. Always Use CodeMirror 6
CM6 is a complete rewrite with a modern, modular architecture. It offers superior performance, extensibility, and maintainability. CM5 is deprecated and unsupported.
❌ BAD: Using CodeMirror 5
// Old CM5 global API
var myCodeMirror = CodeMirror(document.body, {
value: "function myScript(){}",
mode: "javascript"
});
✅ GOOD: Using CodeMirror 6
import { EditorState } from "@codemirror/state";
import { EditorView, basicSetup } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
const view = new EditorView({
state: EditorState.create({
doc: "console.log('Hello CM6!');",
extensions: [basicSetup, javascript()],
}),
parent: document.body,
});
2. Import Only What You Need (Modular Imports)
CM6 is designed for tree-shaking. Only import the specific packages and modules required to minimize bundle size and improve load times.
❌ BAD: Importing monolithic packages (if they existed for CM6)
// Hypothetical bad example: importing a giant 'codemirror-all' package
import { EditorView, EditorState, basicSetup, javascript, ... } from "codemirror-all";
✅ GOOD: Granular, specific imports
import { EditorState } from "@codemirror/state"; // Core state management
import { EditorView, keymap } from "@codemirror/view"; // Core view rendering
import { defaultKeymap } from "@codemirror/commands"; // Standard editing commands
import { javascript } from "@codemirror/lang-javascript"; // Language support
3. Configure Exclusively via Extensions
All editor features, from basic setup to complex language services, must be added as extensions to the EditorState. Avoid direct DOM manipulation or non-extension-based configuration.
❌ BAD: Direct property assignment or DOM manipulation
const view = new EditorView({ state: EditorState.create({ doc: "" }) });
// Don't do this: Bypassing the extension system
view.dom.style.fontSize = "16px";
✅ GOOD: Everything is an extension
import { EditorState } from "@codemirror/state";
import { EditorView, lineNumbers } from "@codemirror/view";
import { oneDark } from "@codemirror/theme-one-dark"; // A theme extension
const view = new EditorView({
state: EditorState.create({
doc: "const x = 1;",
extensions: [
lineNumbers(), // Line numbers extension
oneDark, // Theme extension
EditorView.lineWrapping, // Line wrapping extension
EditorState.tabSize.of(2), // Configuration option as an extension
],
}),
parent: document.body,
});
Common Patterns and Anti-patterns
4. Embrace Immutable State Management
EditorState objects are immutable. All changes must be applied by creating a Transaction and dispatching it to the EditorView. This enables reliable undo/redo, time-travel debugging, and predictable behavior.
❌ BAD: Mutating EditorState or Text directly
let state = EditorState.create({ doc: "hello" });
// This will NOT work as expected and breaks CM6's core design
// state.doc = Text.of("world");
// console.log(state.doc.toString()); // Still "hello"
✅ GOOD: Using Transaction and dispatch
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
const startState = EditorState.create({ doc: "Hello World" });
const view = new EditorView({ state: startState, parent: document.body });
// Create a transaction to change the document
const transaction = view.state.update({
changes: { from: 0, to: 5, insert: "Goodbye" }, // Change "Hello" to "Goodbye"
selection: { anchor: 7 }, // Move cursor
// Add other state changes like effects or annotations here
});
view.dispatch(transaction); // Apply the transaction
console.log(view.state.doc.toString()); // "Goodbye World"
5. Prioritize TypeScript for New Code
For new CodeMirror components, always use TypeScript. It provides static type checking, improved IDE support, and reduces runtime errors, leading to more robust and maintainable code.
❌ BAD: Writing new complex modules in plain JavaScript
// my-complex-cm-plugin.js
export function myComplexPlugin() {
// No type safety, easy to introduce bugs
return ViewPlugin.define(view => ({
update(update) {
if (update.docChanged) {
// What type is update.docChanged?
}
}
}));
}
✅ GOOD: Writing new complex modules in TypeScript
// my-complex-cm-plugin.ts
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { Extension } from "@codemirror/state";
export function myComplexPlugin(): Extension {
return ViewPlugin.define(view => ({
// Explicitly typed parameters for clarity and safety
update(update: ViewUpdate) {
if (update.docChanged) {
console.log("Document changed:", update.changes.toString());
}
},
}));
}
Performance Considerations
6. Batch Editor Updates
Avoid dispatching multiple transactions in quick succession. Group related changes into a single Transaction to minimize DOM updates and improve performance.
❌ BAD: Frequent, unbatched dispatches
// In a loop or event handler
for (let i = 0; i < 100; i++) {
view.dispatch(view.state.update({ changes: { from: i, insert: "a" } }));
}
✅ GOOD: Batching changes into a single transaction
let changes = [];
for (let i = 0; i < 100; i++) {
changes.push({ from: i, insert: "a" });
}
view.dispatch(view.state.update({ changes })); // Single dispatch
7. Debounce/Throttle External Event Handlers
When external events (e.g., window resize, network responses, user input outside the editor) trigger editor updates or expensive computations, always debounce or throttle them to prevent excessive processing.
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { javascript } from "@codemirror/lang-javascript";
const view = new EditorView({
state: EditorState.create({
doc: "function expensiveLint() { /* ... */ }",
extensions: [javascript()],
}),
parent: document.body,
});
// Assume `lintDocument` is an expensive operation
const debouncedLint = debounce(() => {
// Trigger linting based on current editor state
console.log("Linting document...");
}, 500);
// Listen for editor state changes
view.dom.addEventListener("input", debouncedLint);
function debounce(func, delay) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}
Common Pitfalls and Gotchas
8. Never Mix CodeMirror 5 and 6
The architectures are fundamentally different. Attempting to use CM5 add-ons or APIs with a CM6 editor will lead to unpredictable behavior and errors.
❌ BAD: Using CM5 add-ons with CM6
// This will NOT work with CM6
import "codemirror/addon/search/searchcursor";
✅ GOOD: Use CM6-native extensions
// For search, use CM6's @codemirror/search
import { search, searchKeymap, highlightSelectionMatches } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
const view = new EditorView({
state: EditorState.create({
doc: "search me",
extensions: [
search(),
keymap.of(searchKeymap),
highlightSelectionMatches(),
],
}),
parent: document.body,
});
9. Understand Extension Precedence
When multiple extensions affect the same aspect of the editor (e.g., keymaps, decorations), their order in the extensions array and explicit Prec modifiers determine precedence. Debug carefully if an extension isn't behaving as expected.
import { EditorState, Prec } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
// A custom keymap that should override default behavior
const myCustomKeymap = keymap.of([
{
key: "Mod-s",
run: (view) => {
console.log("Custom save action!");
return true; // Indicate that the event was handled
},
},
]);
const view = new EditorView({
state: EditorState.create({
doc: "Press Cmd-S",
extensions: [
defaultKeymap, // Default keymap is defined first
Prec.highest(myCustomKeymap), // Custom keymap with highest precedence will override
],
}),
parent: document.body,
});
Testing Approaches
10. Unit Test EditorState Logic
Isolate and unit test functions that operate on or produce EditorState objects. Since EditorState is immutable, these functions are pure and easy to test without a browser environment.
// my-state-transformer.js
import { EditorState } from "@codemirror/state";
export function addCommentToLine(state, lineNumber, commentText) {
const line = state.doc.line(lineNumber);
return state.update({
changes: { from: line.from, insert: `// ${commentText}\n` + line.text },
});
}
// my-state-transformer.test.js
import { EditorState } from "@codemirror/state";
import { addCommentToLine } from "./my-state-transformer";
test("addCommentToLine adds comment correctly", () => {
const initialState = EditorState.create({ doc: "line 1\nline 2" });
const newState = addCommentToLine(initialState, 2, "My comment");
expect(newState.doc.toString()).toBe("line 1\n// My comment\nline 2");
});
11. Use End-to-End Tests for EditorView Interactions
For testing UI interactions (typing, selection, scrolling, extension rendering), use browser-based end-to-end testing frameworks like Playwright or Cypress. These simulate real user behavior.
// playwright.test.js (conceptual example)
import { test, expect } from "@playwright/test";
test("editor displays line numbers and allows typing", async ({ page }) => {
await page.goto("http://localhost:3000/editor"); // Your app's editor page
const editor = page.locator(".cm-editor");
await expect(editor).toBeVisible();
// Check if line numbers are visible
await expect(page.locator(".cm-lineNumbers")).toBeVisible();
// Type into the editor
await editor.type("Hello, CodeMirror!");
await expect(editor).toHaveText(/Hello, CodeMirror!/);
});