Code Editor Component (Monaco/CodeMirror)
Embedding a code editor with language modes, linting, and lazy loading for bundle size.
Component PatternsAdvanced
Why
A textarea is fine for one-line input but useless for code: no syntax highlighting, no indentation, no lint. Monaco or CodeMirror give an IDE-grade experience, but they're heavy — lazy loading is mandatory.
Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.
When
Use a code editor for config editing (JSON/YAML), query input (SQL/GraphQL), or in-browser IDE features. For plain text, a textarea is lighter.
How
1. Pick — Monaco (VS Code engine, heavy ~2MB) or CodeMirror 6 (modular, ~200KB)
2. Lazy load — React.lazy + dynamic import, show a textarea placeholder until loaded
3. Language — register only the modes you need (json, sql), not the whole language pack
4. Lint — wire a LanguageServer or a simple linter function to editor.onDidChange
5. Theme — match app theme; Monaco themes are JSON, CodeMirror uses @codemirror/theme-one-dark
6. Value sync — controlled mode is tricky; use editor.setValue on external change, guard against loops
7. Worker — Monaco uses web workers for language services; configure MonacoEnvironment.getWorkerUrl
Example
import { lazy, Suspense, useState } from "react";
// Lazy load Monaco only when the editor mounts
const MonacoEditor = lazy(() => import("@monaco-editor/react").then((m) => ({ default: m.default })));
function CodeEditor({ value, onChange, language = "json" }: Props) {
const [loaded, setLoaded] = useState(false);
return (
<Suspense fallback={<textarea value={value} onChange={(e) => onChange(e.target.value)}
className="w-full h-64 font-mono" />}>
<MonacoEditor
value={value}
language={language}
theme="vs-dark"
onChange={(v) => onChange(v ?? "")}
onMount={() => setLoaded(true)}
options={{ minimap: { enabled: false }, fontSize: 13, lineNumbers: "on" }}
loading={<div className="grid place-items-center h-64">Loading editor…</div>}
/>
</Suspense>
);
}
Common Problems
- ⚠Monaco in the main bundle — 2MB ships to every user even those who never open the editor
- ⚠Worker URL misconfigured — language services silently fail, no autocomplete or diagnostics
- ⚠Controlled value loop — onChange sets state, setValue triggers onChange again, cursor jumps
Best Practices
- ✓Lazy-load the editor — dynamic import on first use, textarea fallback keeps the page light
- ✓Register only needed languages — Monaco's full language pack is huge, import just json/sql
- ✓Use the uncontrolled model — let the editor own its value, read on submit, avoid setValue loops