Skip to content
Back to Design Hub

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

FAQ