Skip to main content

CodeEditor

A CodeMirror 6 code editing surface with JS/TS highlighting, line wrapping, a Cmd+S hook, and an optional unified diff view.

import CodeEditor from '@jbpark/ui-kit/CodeEditor';

<CodeEditor value={code} onChange={setCode} height="200px" />;
Subpath-only, with optional peers

CodeEditor is not exported from the package root — import { CodeEditor } from '@jbpark/ui-kit' will not work, and that is deliberate. CodeMirror is a set of optional peer dependencies, so pulling the editor through the root barrel would make every consumer resolve CodeMirror just to import a Button.

Install the peer set alongside the library to use this subpath:

pnpm add @uiw/react-codemirror @uiw/codemirror-theme-vscode \
@codemirror/lang-javascript @codemirror/merge codemirror

All five are needed even if you never render a diff — @codemirror/merge is imported statically by the component.

Press ⌘S / Ctrl+S to format and save.

Props​

Every @uiw/react-codemirror prop is accepted (editable, readOnly, basicSetup, …). The additions and overrides:

PropTypeDefault
valuestring— (required)
heightstring'100%'
theme'light' | 'dark' | 'auto' | 'none' | Extension'auto'
languageExtension | nullJS/TS/JSX
extensionsExtension[]-
diff{ original: string; mergeControls?: boolean }-
formatCode(code: string) => Promise<string>-
onChange(value: string) => void-
onSave(value: string) => void-
onFormatError(error: string | null) => void-

CodeEditor is always controlled via value/onChange — there is no defaultValue.

Theme​

theme takes the VSCode pair by name, follows the app's dark mode, or accepts any CodeMirror theme extension:

<CodeEditor value={code} /> // 'auto' — follows Config
<CodeEditor value={code} theme="dark" /> // vscodeDark, regardless
<CodeEditor value={code} theme={dracula} /> // your own theme extension
<CodeEditor value={code} theme="none" /> // no theme at all; style it yourself

'auto' is the default and reads theme.dark from the nearest Config, with 'system' resolved against prefers-color-scheme:

<Config theme={{ dark: 'system' }}>
<CodeEditor value={code} onChange={setCode} />
</Config>

With no Config — or one that doesn't set dark — 'auto' is light, so nothing changes for an app that never opted into dark mode.

note

'auto' only sees dark mode that goes through Config. Toggling the .dark class yourself (which is what this documentation site does, so that <body> is covered too) leaves the editor on light, so pass theme explicitly there:

const { colorMode } = useColorMode();

<CodeEditor value={code} theme={colorMode} />;

Note also that 'light'/'dark' mean the VSCode themes here, not CodeMirror's own same-named built-ins.

Format on save​

The component carries no formatter. Pass formatCode and Cmd/Ctrl+S runs it, writes the result back (keeping the cursor in place), then fires onChange and onSave with the formatted text. Without formatCode the shortcut still fires onSave, it just doesn't reformat.

<CodeEditor
value={code}
formatCode={formatWithPrettier}
onChange={setCode}
onSave={persist}
onFormatError={setError}
/>

Keeping the formatter out of the package is intentional: prettier is ~9.6 MB, and only apps that want format-on-save should pay for it. onFormatError receives null on a clean save, or the message a rejected formatCode threw — it is named that way because the underlying props already carry the DOM onError handler.

Two safeguards apply to the write-back:

  • If the document changed while an async formatCode was in flight, the result is discarded rather than clobbering newer keystrokes.
  • If the formatted text is identical to the input, no transaction is dispatched (so Cmd+S doesn't push a no-op undo entry). The callbacks still fire.

Diff view​

diff swaps the plain document for CodeMirror's unifiedMergeView, diffing the current content against diff.original — useful as a read-only review step before persisting a change. mergeControls defaults to false; pass true for per-chunk accept/reject gutters.

<CodeEditor value={current} diff={{ original: lastSaved }} editable={false} />

Extensions and language support​

The built-in extension set is a language mode plus EditorView.lineWrapping. The language mode defaults to javascript({ jsx: true, typescript: true }); language replaces it, and null turns highlighting off for plain text:

import { json } from '@codemirror/lang-json';

<CodeEditor value={code} language={json()} /> // JSON instead of JS/TS
<CodeEditor value={notes} language={null} /> // plain text

Other language packages are not peers of this library — install the ones you pass. @codemirror/lang-javascript stays required either way, because the default is imported statically.

Anything passed in extensions is appended after the language mode, so a keymap or a linter can be layered on without forking the component. Like extensions, a new language value reconfigures the editor, so create it once (at module scope or with useMemo) rather than on every render.