zudo-codemirror

Type to search...

to open search from anywhere

Architecture

CreatedMar 29, 2026Takeshi Takatsudo

Overview of CodeMirror 6's architecture: functional core, immutable state, transaction-based updates, and the extension system.

Architecture

CodeMirror 6 is a ground-up rewrite that separates concerns into a functional core and an imperative shell. This page covers the fundamental design decisions that shape how you work with the editor.

Functional Core and Imperative Shell

The architecture splits into two layers:

  • EditorState (from @codemirror/state) — the functional core. It holds the document, selection, and all configured state. It is immutable; you never mutate it directly.
  • EditorView (from @codemirror/view) — the imperative shell. It owns the DOM, handles rendering, and coordinates user interaction. It holds a reference to the current EditorState and updates it through transactions.

This separation means that state logic can be tested and reasoned about without touching the DOM.

Immutable State Model

EditorState is immutable. To change anything — the document content, the selection, a state field — you create a transaction that describes the change. Applying that transaction produces a new EditorState. The old state remains unchanged.

import { EditorState } from "@codemirror/state";

let state = EditorState.create({ doc: "Hello" });
let tr = state.update({ changes: { from: 5, insert: " World" } });
let newState = tr.state;

console.log(state.doc.toString()); // "Hello"
console.log(newState.doc.toString()); // "Hello World"

This is fundamentally different from CodeMirror 5, which used a mutable model where calling methods like setValue() or replaceRange() directly mutated the editor’s internal state.

Transaction-Based Updates

Every state change goes through a transaction. A transaction can contain:

  • Document changes (insertions, deletions, replacements)
  • Selection updates
  • Annotations (metadata attached to the transaction)
  • State effects (signals for state fields and other extensions)

When working with EditorView, you call view.dispatch(transaction) to apply the transaction, which updates both the state and the DOM.

// Dispatch a change through the view
view.dispatch({
  changes: { from: 0, insert: "// " },
});

Transactions are described in detail on the Transactions page.

Extension System

CodeMirror 6 has no built-in behavior beyond rendering text. Everything else — line numbers, syntax highlighting, keybindings, bracket matching — is provided through extensions.

An extension is a value (or array of values) that you pass to EditorState.create(). Extensions compose by flattening into a single array, so you can group related extensions into bundles and combine them freely.

import { EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";

let state = EditorState.create({
  doc: "Hello",
  extensions: [lineNumbers(), keymap.of(defaultKeymap)],
});

The extension system is covered on the Extensions page.

Facets and State Fields

Two core mechanisms make extensions work:

Facets

A facet is a named extension point that collects values from multiple providers and combines them. Facets are used for configuration that needs to merge contributions from different extensions.

For example, EditorState.tabSize is a facet. Multiple extensions could theoretically provide a tab size, but the facet’s combining function determines the final value. Some facets combine by taking the first provided value; others collect all values into an array.

import { EditorState } from "@codemirror/state";

let state = EditorState.create({
  extensions: [EditorState.tabSize.of(4)],
});
console.log(state.facet(EditorState.tabSize)); // 4

State Fields

A state field holds a piece of computed state that updates with each transaction. Unlike facets, state fields maintain their own value across transactions using create and update functions.

import { StateField } from "@codemirror/state";

let wordCount = StateField.define({
  create(state) {
    return state.doc.toString().split(/\s+/).filter(Boolean).length;
  },
  update(value, tr) {
    if (tr.docChanged) {
      return tr.state.doc.toString().split(/\s+/).filter(Boolean).length;
    }
    return value;
  },
});

How Extensions Compose

Extensions are flat arrays. When you pass nested arrays, they get flattened:

let myBundle = [lineNumbers(), keymap.of(defaultKeymap)];

let state = EditorState.create({
  extensions: [myBundle, someOtherExtension],
  // Equivalent to: [lineNumbers(), keymap.of(defaultKeymap), someOtherExtension]
});

This makes it straightforward to create reusable bundles. The basicSetup package is itself just a large array of extensions bundled together.

When extensions conflict (for example, two keymaps binding the same key), the order matters: earlier extensions in the array take precedence by default. You can override this with Prec.highest, Prec.high, Prec.low, and Prec.lowest.

Comparison with CodeMirror 5

AspectCodeMirror 5CodeMirror 6
State modelMutable, in-place updatesImmutable, transaction-based
DOMSingle textarea or contentEditableCustom rendering with EditorView
ConfigurationOptions object with setOption()Extensions, facets, and compartments
ExtendingEvent listeners, overlays, addonsFacets, state fields, view plugins
BundlingSingle monolithic scriptModular packages (@codemirror/*)
TypeScriptCommunity type definitionsWritten in TypeScript, types included

The modular design means you only bundle the features you use, and the immutable state model makes it possible to reason about changes more predictably than CM5’s event-driven mutation approach.

Revision History