Back in my early days, I assumed a markdown editor was a serious project. A parser, a preview pane, keeping the two in sync, all of it felt like a weekend at least. Then I built one and the whole thing turned out to be a textarea, a library, and about 80 lines. Here is the shape of it.
Try it: type on the left, watch the right
Try it live on nayemalam.com.
The shape of it
Two panes, one piece of state. The textarea holds the raw markdown in a useState, and the preview is just a function of that state. Type a character, the state updates, the preview re-renders. There is no syncing logic to write, because there is only one source of truth and two views of it.
Rendering the markdown
Writing your own parser is a fun exercise and a terrible idea for a real editor. The react-markdown library does the parsing, and it builds React elements instead of an HTML string. That is the whole trick: there is no HTML string anywhere, so there is no dangerouslySetInnerHTML and nothing to sanitize. Raw HTML in the input is ignored by default, and React escapes the text for you.
terminal
npm install react-markdown MarkdownEditor.jsx
import { useState, useMemo } from 'react'; import Markdown from 'react-markdown'; export default function MarkdownEditor() { const [text, setText] = useState('# Hello\n\nType here, watch it render.'); const preview = useMemo(() => <Markdown>{text}</Markdown>, [text]); return ( <div className="flex h-[600px]"> <textarea value={text} onChange={(e) => setText(e.target.value)} className="w-1/2 p-4 font-mono" /> <div className="w-1/2 overflow-auto p-4"> {preview} </div> </div> ); } The safety here is not a step you add, it is the default. A script tag typed into the editor shows up as text on the page, it never runs. The old way, parsing to an HTML string and injecting it, needed a sanitizer for exactly this reason. This way does not.
Making it feel realtime
Every keystroke updates the state, so the preview updates on every keystroke. That is the whole trick, and it is fast enough for any doc a person actually types. The one thing worth adding is useMemo around the preview, so a long doc only re-parses when the text changes, not on every render.
The nice touch: synced scrolling
A preview that does not follow you is only half an editor. Ten lines fix it: when the textarea scrolls, work out how far down it is as a ratio, and set the preview to the same ratio.
sync-scroll.js
const editorRef = useRef(null); const previewRef = useRef(null); const syncScroll = () => { const editor = editorRef.current; const preview = previewRef.current; const ratio = editor.scrollTop / (editor.scrollHeight - editor.clientHeight); preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight); }; // textarea: ref={editorRef} onScroll={syncScroll} // preview: ref={previewRef} On a phone
Two panes side by side do not fit a phone, so the layout stacks: editor on top, preview below, with flex-col md:flex-row and the panes going from full width to half. Same component, same state, the CSS does the work.
So, what have I learnt?
- The editor is not the hard part. The parser is a library, the layout is flexbox, the state is one string.
- Render markdown to elements, not to an HTML string. That is what makes the whole thing safe by default, no sanitizer needed.
- The preview is just a function of the state. One source of truth, two views.
-
useMemomatters once the doc gets long. Parsing on every render is fine at 20 lines, sluggish at 2000. - Syncing the scroll is ten lines, and it is what makes the whole thing feel finished.
Originally posted on nayemalam.com/blog.

