What Is a Markdown Editor?
A Markdown editor is a text input tool that allows users to write content using Markdown syntaxβa lightweight markup language with plain-text formattingβand instantly see a rendered HTML preview. Unlike traditional rich-text editors, Markdown editors keep the raw syntax visible while displaying the formatted output side by side (or inline), giving writers full control over their content structure without hiding the underlying markup.
Building one with React combines two powerful ideas: controlled components for real-time state management and markdown-to-HTML parsing for live previews. The result is a fast, interactive writing environment that runs entirely in the browser.
Why Build One with React?
- Real-time previews β React's state-driven rendering means any keystroke immediately triggers a re-render of the preview pane
- Component reusability β you can package the editor as a standalone component and drop it into any React project
- Extensibility β adding toolbars, syntax highlighting, dark mode, or custom plugins becomes straightforward with React's composition model
- No server required β everything runs client-side, making it ideal for note-taking apps, documentation tools, and CMS interfaces
- Learning value β it touches on controlled inputs, third-party library integration, debouncing, and UI layout patterns all in one project
Project Setup
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Create a new React project using Vite (fast and minimal) or Create React App. The following steps assume Vite, but the component code works identically in any React setup.
npm create vite@latest markdown-editor -- --template react
cd markdown-editor
npm install
Next, install the marked libraryβa battle-tested Markdown parser that converts raw Markdown strings into sanitized HTML. You'll also install DOMPurify to prevent XSS attacks from untrusted Markdown input.
npm install marked dompurify
npm install -D @types/dompurify
The project structure will be minimal. You'll create a single MarkdownEditor component and use it inside App.jsx.
src/
βββ App.jsx
βββ App.css
βββ MarkdownEditor.jsx
βββ MarkdownEditor.css
βββ main.jsx
Building the Core Component
The editor consists of two main panels: a raw Markdown textarea on the left and an HTML preview div on the right. Both are driven by a single piece of stateβthe Markdown string. When the user types, React updates state, which triggers the parser, which updates the preview.
Step 1: Create the MarkdownEditor Component
Create MarkdownEditor.jsx with the following structure. Notice how the useState hook holds the Markdown text, and useMemo caches the parsed HTML so it only recomputes when the text changes.
import { useState, useMemo } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import './MarkdownEditor.css';
export default function MarkdownEditor() {
const [markdown, setMarkdown] = useState(() => {
// Default starter text so the editor isn't empty
return `# Welcome to Your Markdown Editor
## Getting Started
This is a **live** preview editor. Type Markdown on the left and see the rendered HTML on the right.
### Features
- Real-time preview
- Syntax you already know
- Clean, minimal interface
> "The best writing tool is the one you actually use."
\`\`\`javascript
// Try inline code blocks
const greet = (name) => \`Hello, \${name}!\`;
console.log(greet('World'));
\`\`\`
Enjoy writing! β¨
`;
});
// Parse markdown and sanitize the resulting HTML
const html = useMemo(() => {
const rawHtml = marked.parse(markdown, { breaks: true, gfm: true });
return DOMPurify.sanitize(rawHtml);
}, [markdown]);
const handleChange = (e) => {
setMarkdown(e.target.value);
};
return (
Markdown
Preview
);
}
Step 2: Add the Companion CSS
Create MarkdownEditor.css to style the split-pane layout. The CSS uses flexbox for the side-by-side panels and includes basic typography for the preview output.
/* MarkdownEditor.css */
.editor-container {
display: flex;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f9fafb;
}
.editor-pane,
.preview-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; /* prevent flex overflow */
}
.editor-pane {
border-right: 1px solid #e5e7eb;
}
.pane-title {
padding: 12px 16px;
margin: 0;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
background-color: #ffffff;
border-bottom: 1px solid #e5e7eb;
user-select: none;
}
.markdown-input {
flex: 1;
padding: 16px;
border: none;
outline: none;
resize: none;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
color: #1f2937;
background-color: #ffffff;
}
.markdown-input::placeholder {
color: #9ca3af;
}
.preview-content {
flex: 1;
padding: 16px;
overflow-y: auto;
background-color: #ffffff;
color: #374151;
line-height: 1.7;
word-wrap: break-word;
}
/* Preview typography styles */
.preview-content h1 {
font-size: 2em;
margin-top: 0;
padding-bottom: 0.3em;
border-bottom: 1px solid #e5e7eb;
}
.preview-content h2 {
font-size: 1.5em;
margin-top: 1.5em;
padding-bottom: 0.2em;
border-bottom: 1px solid #e5e7eb;
}
.preview-content h3 {
font-size: 1.25em;
margin-top: 1.2em;
}
.preview-content p {
margin: 0.8em 0;
}
.preview-content blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #d1d5db;
color: #6b7280;
background-color: #f3f4f6;
}
.preview-content pre {
background-color: #1f2937;
color: #e5e7eb;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
}
.preview-content code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
}
.preview-content pre code {
background: none;
padding: 0;
}
.preview-content ul,
.preview-content ol {
padding-left: 1.5em;
}
.preview-content li {
margin: 0.3em 0;
}
.preview-content a {
color: #2563eb;
text-decoration: underline;
}
.preview-content img {
max-width: 100%;
}
.preview-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.preview-content th,
.preview-content td {
border: 1px solid #d1d5db;
padding: 8px 12px;
text-align: left;
}
.preview-content th {
background-color: #f3f4f6;
font-weight: 600;
}
Step 3: Wire It into App.jsx
Replace the contents of App.jsx to use the new component. You can keep the default Vite styles or remove them entirely.
import MarkdownEditor from './MarkdownEditor';
function App() {
return (
);
}
export default App;
Run the app with npm run dev. You should see a split-screen editor with starter Markdown on the left and rendered HTML on the right. Typing in the textarea updates the preview in real time.
How It Works Under the Hood
Let's walk through the data flow step by step:
- Controlled textarea β The
<textarea>usesvalue={markdown}andonChange={handleChange}, making React the single source of truth for the text content. Every keystroke callssetMarkdownwith the new full string. - useMemo caching β The
marked.parse()call is wrapped inuseMemowith[markdown]as the dependency. This ensures parsing only runs when the Markdown string actually changes, avoiding unnecessary computation on re-renders triggered by other state. - GFM and breaks β The options
{ breaks: true, gfm: true }enable GitHub-Flavored Markdown (tables, strikethrough, task lists) and convert single line breaks into<br>tags, matching the behavior users expect from platforms like GitHub. - DOMPurify sanitization β Before inserting HTML via
dangerouslySetInnerHTML, the parsed output passes throughDOMPurify.sanitize(). This strips out dangerous tags like<script>, event handler attributes, and other XSS vectors, making the preview safe even if you're rendering user-submitted Markdown.
Adding a Toolbar
A toolbar elevates the editor from a basic textarea to a full-featured writing tool. You'll add buttons that insert Markdown syntax at the cursor position, giving users shortcuts for bold, italic, headings, links, and code blocks.
Extracting Toolbar Logic
Create a helper function that inserts text at the cursor in a textarea. This uses the selectionStart and selectionEnd properties of the textarea element.
// toolbarHelpers.js
/**
* Inserts a string at the cursor position in a textarea.
* If text is selected, wraps it with the given before/after strings.
*
* @param {HTMLTextAreaElement} textarea - The target textarea
* @param {string} before - Text to insert before the selection
* @param {string} after - Text to insert after the selection
* @param {string} defaultPlaceholder - Text to use if nothing is selected
*/
export function insertAtCursor(textarea, before, after, defaultPlaceholder = 'text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
let replacement;
if (selectedText) {
replacement = before + selectedText + after;
} else {
replacement = before + defaultPlaceholder + after;
}
// Update the textarea value via its native setter to trigger React's onChange
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
)?.set;
nativeInputValueSetter?.call(textarea,
textarea.value.substring(0, start) +
replacement +
textarea.value.substring(end)
);
// Dispatch an input event so React picks up the change
const event = new Event('input', { bubbles: true });
textarea.dispatchEvent(event);
// Set cursor position after insertion
const newCursorPos = start + replacement.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
}
Adding the Toolbar Component
Update MarkdownEditor.jsx to include a toolbar above the textarea. Each button calls insertAtCursor with the appropriate Markdown syntax.
import { useState, useMemo, useRef } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { insertAtCursor } from './toolbarHelpers';
import './MarkdownEditor.css';
const TOOLBAR_ITEMS = [
{
label: 'Bold',
icon: 'B',
action: (textarea) => insertAtCursor(textarea, '**', '**', 'bold text'),
},
{
label: 'Italic',
icon: 'I',
action: (textarea) => insertAtCursor(textarea, '*', '*', 'italic text'),
},
{
label: 'Heading',
icon: 'H',
action: (textarea) => insertAtCursor(textarea, '\n## ', '', 'Heading'),
},
{
label: 'Link',
icon: 'π',
action: (textarea) => insertAtCursor(textarea, '[', '](url)', 'link text'),
},
{
label: 'Code',
icon: '</>',
action: (textarea) => insertAtCursor(textarea, '`', '`', 'code'),
},
{
label: 'Code Block',
icon: 'π',
action: (textarea) => insertAtCursor(textarea, '\n\n', '\n\n', 'code block'),
},
{
label: 'Quote',
icon: 'β',
action: (textarea) => insertAtCursor(textarea, '\n> ', '', 'quote'),
},
{
label: 'List',
icon: 'β’',
action: (textarea) => insertAtCursor(textarea, '\n- ', '', 'list item'),
},
];
export default function MarkdownEditor() {
const [markdown, setMarkdown] = useState(() => {
return `# Welcome to Your Markdown Editor
Start typing or use the toolbar above to format your text.
## Features
- **Bold** and *italic* formatting
- Headings and blockquotes
- \`inline code\` and code blocks
- [Links](https://example.com) and lists
Happy writing! π
`;
});
const textareaRef = useRef(null);
const html = useMemo(() => {
const rawHtml = marked.parse(markdown, { breaks: true, gfm: true });
return DOMPurify.sanitize(rawHtml);
}, [markdown]);
const handleChange = (e) => {
setMarkdown(e.target.value);
};
const handleToolbarAction = (action) => {
if (textareaRef.current) {
action(textareaRef.current);
}
};
return (
Markdown
{/* Toolbar */}
{TOOLBAR_ITEMS.map((item) => (
))}
Preview
);
}
Toolbar CSS Additions
Append these styles to MarkdownEditor.css:
/* Toolbar styles */
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
background-color: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
.toolbar-button {
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #f9fafb;
color: #374151;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.toolbar-button:hover {
background-color: #e5e7eb;
border-color: #9ca3af;
}
.toolbar-button:active {
background-color: #d1d5db;
transform: scale(0.97);
}
How to Use the Editor in Practice
Integrating into an Existing App
The MarkdownEditor component is self-contained. To drop it into any React project:
- Copy the
MarkdownEditor.jsx,MarkdownEditor.css, andtoolbarHelpers.jsfiles into your project - Ensure
markedanddompurifyare installed as dependencies - Import and render
<MarkdownEditor />wherever you need it
If you need to access the Markdown content outside the component (for saving to a database or exporting), lift the state up by accepting a value prop and an onChange callback:
// Parent component usage
const [content, setContent] = useState('');
return (
<MarkdownEditor
value={content}
onChange={(newMarkdown) => setContent(newMarkdown)}
/>
);
Then modify MarkdownEditor to use the props instead of internal state:
export default function MarkdownEditor({ value, onChange }) {
const markdown = value;
const handleChange = (e) => {
onChange?.(e.target.value);
};
// ... rest stays the same, using markdown and handleChange
}
Persisting Content with localStorage
Add automatic saving so users don't lose their work. Use a useEffect that writes to localStorage whenever the Markdown changes, and initialize state from localStorage on mount.
const STORAGE_KEY = 'markdown-editor-content';
const [markdown, setMarkdown] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved || '# Start writing...';
});
// Persist changes with a debounce to avoid excessive writes
useEffect(() => {
const timer = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, markdown);
}, 500);
return () => clearTimeout(timer);
}, [markdown]);
Exporting as HTML or Markdown File
Add export buttons that trigger file downloads. Here's a helper to download content as a file:
function downloadFile(content, filename, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// Export raw Markdown
const exportMarkdown = () => {
downloadFile(markdown, 'document.md', 'text/markdown');
};
// Export rendered HTML
const exportHtml = () => {
downloadFile(html, 'document.html', 'text/html');
};
Best Practices
- Always sanitize β Never pass raw
marked.parse()output directly todangerouslySetInnerHTMLwithout DOMPurify or an equivalent sanitizer. Markdown can contain arbitrary HTML, and malicious input can execute scripts. - Use
useMemofor parsing β Parsing Markdown is computationally inexpensive for typical documents, but wrapping it inuseMemoprevents re-parsing on unrelated re-renders and sets up good habits for when you add more expensive transformations. - Debounce persistence β When saving to
localStorage, an API, or a database, debounce writes by 300β500ms. This prevents hammering the storage layer on every keystroke while keeping saves frequent enough to feel instant. - Keep the textarea uncontrolled for performance? No. β A controlled textarea is the right pattern here because you need the value for parsing. The performance overhead is negligible for text under 100KB. If you encounter slowness with very large documents, consider
react-windowor splitting the document into sections. - Respect cursor position β When inserting text programmatically (toolbar buttons, autocomplete), always restore the cursor to the expected position. The
insertAtCursorhelper above does this correctly by dispatching an input event rather than directly callingsetMarkdown. - Use
useReffor the textarea β You need a ref to accessselectionStartandselectionEndfor cursor-aware insertions. Don't try to derive these from state alone. - Provide default content β A well-crafted starter document serves as both documentation and a tutorial. Users immediately see what the editor can do.
- Make the preview responsive β On mobile screens, consider stacking the panes vertically instead of side by side. Use a media query in CSS to switch the flex direction at narrower breakpoints.
- Add keyboard shortcuts β Power users expect shortcuts like Ctrl+B for bold. You can layer keyboard event handlers on the textarea that call the same toolbar actions.
Going Further: Advanced Enhancements
Once the core editor is working, consider these additions:
- Syntax highlighting in the input β Use a library like
CodeMirrororreact-simple-code-editorto replace the plain textarea with a highlighted Markdown input that shows headings, bold, and links in different colors - Custom renderers β
markedsupports custom renderers. You can override how links, images, or code blocks are renderedβfor example, adding copy-to-clipboard buttons on code blocks or lazy-loading images - Collaborative editing β Integrate with CRDT libraries like
Yjsor operational transform libraries to support multiple simultaneous editors - Plugin system β Allow users to register custom toolbar buttons or Markdown extensions, turning the editor into a platform rather than a single-purpose tool
- Dark mode β Use CSS custom properties (variables) for colors and toggle a class on the root container to switch themes
Complete Working Example
Below is the full, self-contained MarkdownEditor.jsx with toolbar, localStorage persistence, and export functionality. You can copy this single file into any React project that has marked and dompurify installed.
import { useState, useMemo, useRef, useEffect } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import './MarkdownEditor.css';
/* βββββββββ Helper: insert text at cursor βββββββββ */
function insertAtCursor(textarea, before, after, placeholder = 'text') {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
const replacement = selected ? before + selected + after : before + placeholder + after;
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
)?.set;
nativeSetter?.call(
textarea,
textarea.value.substring(0, start) + replacement + textarea.value.substring(end)
);
textarea.dispatchEvent(new Event('input', { bubbles: true }));
const newCursor = start + replacement.length;
textarea.setSelectionRange(newCursor, newCursor);
textarea.focus();
}
/* βββββββββ Helper: download file βββββββββ */
function downloadFile(content, filename, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/* βββββββββ Toolbar configuration βββββββββ */
const TOOLBAR_ITEMS = [
{ label: 'Bold', icon: 'B', action: (ta) => insertAtCursor(ta, '**', '**', 'bold') },
{ label: 'Italic', icon: 'I', action: (ta) => insertAtCursor(ta, '*', '*', 'italic') },
{ label: 'Heading', icon: 'H', action: (ta) => insertAtCursor(ta, '\n## ', '', 'Heading') },
{ label: 'Link', icon: 'π', action: (ta) => insertAtCursor(ta, '[', '](url)', 'link') },
{ label: 'Code', icon: '<>', action: (ta) => insertAtCursor(ta, '`', '`', 'code') },
{ label: 'Code Block', icon: 'π', action: (ta) => insertAtCursor(ta, '\n\n', '\n\n', 'code') },
{ label: 'Quote', icon: 'β', action: (ta) => insertAtCursor(ta, '\n> ', '', 'quote') },
{ label: 'List', icon: 'β’', action: (ta) => insertAtCursor(ta, '\n- ', '', 'item') },
];
const STORAGE_KEY = 'markdown-editor-content';
const DEFAULT_MARKDOWN = `# Welcome to Your Markdown Editor
Start typing or use the toolbar above to format your text.
## What You Can Do
- **Bold** and *italic* formatting
- Headings from H1 to H6
- > Blockquotes for citations
- \`inline code\` and full code blocks
- [Links](https://example.com) and images
- Numbered lists and bullet lists
\`\`\`javascript
// Even syntax-highlighted code blocks!
function greet(name) {
return \`Hello, \${name}!\`;
}
\`\`\`
Happy writing! π
`;
/* βββββββββ Main Component βββββββββ */
export default function MarkdownEditor() {
const [markdown, setMarkdown] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved || DEFAULT_MARKDOWN;
} catch {
return DEFAULT_MARKDOWN;
}
});
const textareaRef = useRef(null);
// Parse markdown β sanitized HTML
const html = useMemo(() => {
const rawHtml = marked.parse(markdown, { breaks: true, gfm: true });
return DOMPurify.sanitize(rawHtml);
}, [markdown]);
// Persist to localStorage (debounced)
useEffect(() => {
const timer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, markdown);
} catch {
// localStorage might be full or unavailable
}
}, 500);
return () => clearTimeout(timer);
}, [markdown]);
const handleChange = (e) => {
setMarkdown(e.target.value);
};
const handleToolbarAction = (action) => {
if (textareaRef.current) {
action(textareaRef.current);
}
};
const handleExportMarkdown = () => {
downloadFile(markdown, 'document.md', 'text/markdown');
};
const handleExportHtml = () => {
const fullHtml = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Document</title></head>
<body>${html}</body>
</html>`;
downloadFile(fullHtml, 'document.html', 'text/html');
};
return (
{/* ββ Editor Panel ββ */}
Markdown
{TOOLBAR_ITEMS.map((item) => (
))}
{/* ββ Preview Panel ββ */}
Preview
);
}
The corresponding CSS additions for the export buttons:
/* Pane title actions */
.pane-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.pane-actions {
display: flex;
gap: 6px;
}
.action-button {
padding: 4px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
background-color: #f3f4f6;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.action-button:hover {
background-color: #e5e7eb;
}
Conclusion
You've now built a complete Markdown editor in Reactβfrom a basic split-pane layout with live preview to a polished tool with a formatting toolbar, localStorage persistence, and file export. The architecture is intentionally simple: a single controlled textarea, a memoized Markdown-to-HTML transformation, and sanitized rendering. This pattern scales naturally. You can swap marked for remark or markdown-it, replace the textarea with a code editor like Monaco or CodeMirror, or embed the component into a larger note-taking or CMS application. The core principle remains the same: treat Markdown as state, parse it reactively, and always sanitize before rendering. With these foundations, you're ready to customize, extend, and deploy your editor anywhere React runs.