Engineering
CodeMirror 6 in React: Language Switching and Bundle Size
Abhishek Bahukhandi

A Taqari interview puts a code editor next to a live voice agent. The candidate types, the agent listens, and a run has to stream test results back without either side stuttering. That editor is CodeMirror 6 behind the React wrapper, and the interesting part is not getting it on screen — it is what happens on every single keystroke afterwards.
This post is about three decisions that turned out to matter: how language switching is wired, where the editor text lives relative to React, and what eleven language packages cost in the bundle. The layers underneath — the template wrapper that turns a function body into a program, batch submission with per-test-case callbacks, and streaming results back over SSE — are covered elsewhere. Here we stay in the browser.
What is actually on the page
The editor is @uiw/react-codemirror, a React component over CodeMirror 6. We pass it four things that matter:
value— the current buffer, held in React.onChange— fired when the document changes.extensions— an array holding the language support and autocompletion.basicSetup— an object switching on line numbers, fold gutter, bracket matching, close brackets, indent on input, search keymap and selection-match highlighting.
The code in our interview editor looks close to this:
const languageExtensions = {
javascript: [javascript({ jsx: false })],
cpp: [cpp()],
java: [java()],
python: [python()],
// ...and seven more
};
<CodeMirror
value={codeText}
onChange={handleCodeChange}
extensions={[languageExtensions[selectedLanguage], autocompletion()]}
theme={oneDark}
basicSetup={{ lineNumbers: true, foldGutter: true, /* ... */ }}
/>
That reads fine. It is also, in two separate ways, the shape that makes CodeMirror rebuild its entire extension tree on every character the candidate types.
The extension model, and what a compartment is for
CodeMirror 6 has no settings object. Everything — language, theme, keymaps, gutters — is an extension, and extensions are combined into one tree when you create the EditorState. Extensions nest arbitrarily deep in arrays and get flattened when processed, which is why [languageExtensions[lang], autocompletion()] is legal.
The tree is immutable. So how do you change the language after the editor exists? The answer the library gives you is a compartment.
Compartments in three calls
A compartment is a box you put around part of the configuration. You install the box once, and later you swap what is inside it without touching anything else. The API is small enough to quote in full from the type definitions shipped in @codemirror/state:
of()
compartment.of(ext) creates the instance you add to your state configuration. You do this when the editor is built, not later.
reconfigure()
compartment.reconfigure(content) returns a StateEffect. You dispatch it like any other effect, and CodeMirror replaces the contents of that one box.
get()
compartment.get(state) reads the current contents back out, or undefined if the compartment is not present. Useful when you want to avoid dispatching a reconfigure that changes nothing.
Put together, language switching is four lines:
const languageConf = new Compartment();
// at creation
extensions: [languageConf.of(python()), autocompletion()]
// later, on a language change
view.dispatch({ effects: languageConf.reconfigure(cpp()) });
The document, the undo history, the selection and the scroll position all survive that, because nothing outside the box was touched.
Why our CodeMirror 6 React editor reconfigured on every keystroke
Here is the part worth the post. @uiw/react-codemirror does not use compartments for the props you pass it. It keeps a single assembled array and, when any of the config props change, dispatches a full reconfigure:
useEffect(() => {
if (view) {
view.dispatch({ effects: StateEffect.reconfigure.of(getExtensions) });
}
}, [theme, extensions, height, /* ... */, onChange, onUpdate]);
Read that dependency array carefully. It contains extensions, and it contains onChange. Both are compared by identity.
Now read our JSX again. extensions={[languageExtensions[selectedLanguage], autocompletion()]} builds a new array literal every render, and calls autocompletion() afresh inside it. And handleCodeChange is declared in the component body, so it is a new function every render too. Two of the dependencies change on every render no matter what the user did.
The loop
Our interview screen holds the buffer in Redux, so the cycle closes on itself:
Nothing here is broken in a way a user reports as a bug. CodeMirror is fast, and a reconfigure on a short file is cheap. It shows up as the editor feeling slightly heavy in a long function, on a mid-range laptop, while a WebRTC audio stream is already competing for the main thread — which is exactly the situation our product is in.
The rule we took from this
In a React wrapper over CodeMirror, every prop in that effect's dependency array is part of your render-performance budget. If a prop is an array literal, an inline arrow, or a fresh extension() call, you are dispatching a full reconfigure on every render. Stabilise the identities or use compartments directly.
The fixes, in order of payoff
Stabilise the extension array first. It is one hook and it removes the per-render array and the per-render autocompletion() call at the same time:
const extensions = useMemo(
() => [languageExtensions[selectedLanguage], autocompletion()],
[selectedLanguage]
);
Then stabilise the change handler with useCallback, so onChange stops changing identity. With both in place, the effect only fires when the language actually changes — which is the one time you genuinely want a reconfigure.
The guard you would have had to write yourself
While you are in there, it is worth seeing what the wrapper is doing on your behalf, because it is the part people get wrong when they hand-roll a controlled editor. When the value prop changes, the wrapper has to push that text into the document — but if it did so blindly it would clobber whatever the candidate is typing at that instant, and the resulting document change would fire onChange again.
So it does two things. Externally-applied changes carry an annotation, and the update listener skips onChange for any transaction marked with it — that kills the echo. And a short typing latch, around 200 ms, defers an incoming value update while the user is mid-keystroke, replaying it once they pause. A controlled editor without both of those loses characters under latency, which is the bug you would otherwise ship and then spend a day failing to reproduce on your own machine.
If you want the compartment behaviour proper, drop to the uncontrolled pattern: create the view yourself, hold a language compartment, and dispatch reconfigure on switch. You give up the convenience of the value prop and take on syncing yourself. For us the memoised version was enough, and the wrapper's own guard against echoing external changes back through onChange is worth keeping.
Where the buffer should live
Our two editors disagree, and the disagreement is instructive. One holds the text in Redux and dispatches on every change. The other takes setCodeText as a prop and keeps the text in the parent's local state.
The Redux version is worse, and not only for the reconfigure loop above. A global store means every keystroke wakes every subscriber to that slice — during a live interview that includes components rendering the transcript and the connection badge. React and Redux both handle this without falling over, but it is work you get nothing for.
The text is local UI state until it means something. It means something at three moments: when the candidate runs the code, when they send it to the interviewer, and when we persist a draft. Dispatch at those three moments, not at character 1,847.
What does belong in the store
The run does. Compiler status, the run id, per-test-case results and progress are all shared across components and arrive asynchronously from the stream — that is precisely what a store is for. The distinction is not "big versus small", it is "does more than one component need to react to this".
Bundle size: eleven languages, mostly unused
Both editor components import every language package at the top of the file — JavaScript, C++, Java, Python, PHP, Rust, HTML, CSS, XML, JSON, Markdown. Static imports, all eleven, unconditionally.
The @codemirror/lang-* packages are thin; together their minified ES modules are around 115 KB. The weight is underneath them, in the Lezer parsers each one pulls in. Measuring the minified ES bundles in our own node_modules:
@lezer/cpp— about 100 KB@lezer/php— about 98 KB@lezer/markdown— about 83 KB@lezer/javascript— about 78 KB@lezer/rust— about 70 KB- the remaining six together — about 130 KB
That is roughly 560 KB of parser code before gzip, sitting behind a dropdown where a coding interview realistically uses one language. Tree shaking does not help: a static import of a package you reference in a lookup table is code you asked for.
Loading grammars on demand
The fix the library authors intend is @codemirror/language-data, a registry of language descriptions whose load functions are dynamic imports. You look up the description for the selected language, await the load, and reconfigure the language compartment when it resolves.
Two details decide whether that feels good or bad. First, the editor must render immediately with no language rather than waiting on the import — syntax highlighting arriving 80 ms late is invisible, a blank editor is not. Second, prefetch the language the candidate is most likely to pick as soon as the problem loads, so the switch is usually already warm.
We also keep the language list honest. A coding interview offering CSS, Markdown, JSON and XML as answer languages is offering noise; the LeetCode-style editor already narrows its dropdown to four real choices, and that narrowing is worth more than any lazy-loading trick.
Two smaller things we got wrong once
Wiping the buffer on a language change
One editor clears the text when the language changes. The other loads the starter template for the new language, falling back to a generic stub if the backend has not sent templates yet. The second is right. Clearing is defensible — the old code will not compile in the new language — but a candidate who fat-fingers the dropdown twenty minutes in loses everything, and CodeMirror's own undo history was just reconfigured out from under them. Swap in the template, keep the old text recoverable.
Treating editor-level paste blocking as a control
CodeMirror lets you intercept DOM events from inside the extension tree:
EditorView.domEventHandlers({
paste: (event, view) => { event.preventDefault(); return true; },
});
It is a clean hook and a fine illustration of how far the extension model reaches. It is also, as an integrity measure, a speed bump — anything that runs in the candidate's browser is negotiable, and a second device defeats it entirely. Interview integrity has to come from signals you collect and from how the conversation is conducted, not from a keydown handler. Treat paste blocking as a nudge, and never let it be the thing you rely on.
What we would tell someone starting today
Use the React wrapper. Hand-rolling the view for a product editor is a week you spend re-implementing value sync and typing guards badly.
But read the wrapper's effect dependencies before you write the JSX, because the props you pass are not inert — an array literal in a prop is a decision about how often the editor rebuilds itself. Memoise the extensions and the change handler on day one. Keep the buffer in local state and dispatch at meaningful moments. Import the two or three grammars you actually offer, and reach for language-data when the list grows past that.
None of this is exotic. It is the ordinary gap between an editor that renders and an editor that stays out of the way while somebody is being interviewed. You can see where it ends up in a real session on Taqari.
Frequently asked questions
How do you switch languages in CodeMirror 6?
+
Wrap the language extension in a Compartment, add it with compartment.of(lang()) when you create the state, then dispatch compartment.reconfigure(newLang()) as a state effect. The document, history and selection survive, because you are replacing one boxed extension rather than rebuilding the editor.
What is a Compartment in CodeMirror 6?
+
A Compartment is a box around part of your extension tree that you can swap out later. You install it once with of(), and reconfigure() returns a StateEffect that replaces only that box's contents. It is how CodeMirror supports dynamic configuration without recreating the EditorState.
Why does my CodeMirror 6 React editor feel slow while typing?
+
Usually because the extensions prop is a new array on every render. React CodeMirror watches that prop and dispatches a full StateEffect.reconfigure when it changes, so the entire extension tree is rebuilt per keystroke. Memoise the array and the onChange handler to stop it.
Should editor text live in Redux or local state?
+
Local state, or the editor's own document. Putting the buffer in a global store means every keystroke dispatches an action and re-renders every subscriber. Push the text to the store on blur, on run, or on a debounce instead of on every character.
How big is CodeMirror 6 with many languages?
+
The language wrappers are small, but each pulls a Lezer parser. In our tree the eleven parsers we imported come to roughly 560 KB of minified JavaScript before gzip, with C++, PHP, Markdown and JavaScript the heaviest. Static imports put all of that in the bundle.
How do you load CodeMirror languages on demand?
+
Use @codemirror/language-data, which holds a registry of language descriptions whose load functions are dynamic imports. You resolve the description for the selected language, await its load, and reconfigure the language compartment with the result once it arrives.