ReactRegexString ParsingdangerouslySetInnerHTMLSplit Pane

Build a Live Markdown Editor

Learn how to build a split-pane markdown editor with real-time preview. You'll write your own markdown-to-HTML parser from scratch using regex and string manipulation — no libraries allowed. This question tests parsing logic, performance awareness, and layout skills.

25 min read8 sections
01

What is a Markdown Editor?

A live markdown editor is a split-pane interface where the user writes raw markdown on one side and sees the rendered HTML preview on the other — updating in real time as they type. It's the same pattern used by GitHub's comment boxes, Notion, and countless documentation tools.

📝

Documentation

README files, wikis, and docs sites all use markdown. Editors with live preview make authoring fast and error-free.

📰

CMS & Blogging

Many headless CMS platforms use markdown for content. A live editor lets authors see formatting without publishing.

🛠️

Developer Tools

Issue trackers, PR descriptions, and code comments all support markdown. Inline preview reduces context switching.

Why interviewers ask this

This question tests multiple skills at once: string parsing with regex, understanding of HTML rendering, performance optimization (re-parsing on every keystroke), and layout skills (responsive split pane). It's a great signal for mid-to-senior frontend roles.

02

Architecture & Layout

The component is straightforward: a textarea on the left, a preview div on the right, and a parser function that converts the markdown string to HTML on every change.

1

User types in the textarea

The onChange handler updates the markdown state with the new value. The input stays responsive because we're only updating a string.

2

Parser converts markdown to HTML

A pure function takes the markdown string and returns an HTML string. This runs on every state change — useMemo prevents unnecessary re-parses when other state changes.

3

Preview renders the HTML

The HTML string is rendered using dangerouslySetInnerHTML on a div. The preview updates instantly as the user types.

Component skeletontypescript
"use client";

import { useState, useMemo } from "react";

function parseMarkdown(md: string): string {
  // Your parser here — returns HTML string
  return md;
}

export default function MarkdownEditor() {
  const [markdown, setMarkdown] = useState("# Hello World");

  // Only re-parse when markdown changes
  const html = useMemo(() => parseMarkdown(markdown), [markdown]);

  return (
    <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
      <textarea
        value={markdown}
        onChange={(e) => setMarkdown(e.target.value)}
        style={{ height: 500, fontFamily: "monospace" }}
      />
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </div>
  );
}

Responsive layout

Use CSS Grid with grid-template-columns: 1fr 1fr for the split pane. On mobile, switch to a single column with a media query or Tailwind's md:grid-cols-2.

03

Parsing Strategy

Markdown parsing happens in two phases: block-level parsing (headings, lists, code blocks, blockquotes) and inline parsing (bold, italic, links, code). Block-level runs first on full lines, then inline runs on the text content within each block.

Two-phase approachtext
Input:  "## Hello **world**"

Phase 1Block parsing:
  Detects "##"wraps in <h2>
  Result: <h2>Hello **world**</h2>

Phase 2Inline parsing:
  Detects "**world**"wraps in <strong>
  Result: <h2>Hello <strong>world</strong></h2>

The order of operations matters. Code blocks must be parsed first (before any inline rules), because content inside code blocks should not be transformed. Similarly, images must be parsed before links (since image syntax ![alt](url) contains link syntax [text](url)).

OrderElementWhy this order
1Fenced code blocksContent inside must not be parsed by other rules
2Headings, HR, blockquotesLine-level patterns — easy to detect by prefix
3Lists (ul / ol)Consecutive lines need to be grouped into one element
4ParagraphsCatch-all for remaining non-empty lines
5Inline (bold, italic, links, code)Runs inside each block's text content
04

Block-Level Parsing

Block-level parsing works line by line. Split the input by newlines, iterate through, and match each line against block patterns. Some blocks (lists, blockquotes) span multiple consecutive lines and need to be grouped.

Fenced Code Blocks

These must be handled first, before splitting into lines, because they can contain characters that look like other markdown syntax.

Code block parsingtypescript
// Run BEFORE line-by-line parsing
// Match ``` ... ``` blocks and wrap in <pre><code>
html = html.replace(
  /```(\w*)\n([\s\S]*?)```/g,
  (_match, _lang, code) =>
    `<pre><code>${escapeHtml(code.trimEnd())}</code></pre>`
);

function escapeHtml(str: string): string {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

Why escapeHtml?

Code blocks can contain HTML-like content (e.g., <div>). Without escaping, the browser would interpret it as real HTML. Always escape content inside code blocks.

Headings

Heading detectiontypescript
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
  const level = headingMatch[1].length; // 1-6
  const text = headingMatch[2];
  output.push(`<h${level}>${parseInline(text)}</h${level}>`);
}

Lists (grouping consecutive lines)

List parsingtypescript
// Unordered list: lines starting with "- " or "* "
if (/^[-*]\s+/.test(line)) {
  const items: string[] = [];
  while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
    items.push(lines[i].replace(/^[-*]\s+/, ""));
    i++;
  }
  output.push(
    `<ul>${items.map(item => `<li>${parseInline(item)}</li>`).join("")}</ul>`
  );
  continue; // skip i++ at end of loop
}

// Ordered list: lines starting with "1. ", "2. ", etc.
if (/^\d+\.\s+/.test(line)) {
  const items: string[] = [];
  while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
    items.push(lines[i].replace(/^\d+\.\s+/, ""));
    i++;
  }
  output.push(
    `<ol>${items.map(item => `<li>${parseInline(item)}</li>`).join("")}</ol>`
  );
  continue;
}

The while loop pattern

Lists and blockquotes are "greedy" blocks — they consume consecutive matching lines. Use a while loop inside your main for loop to collect all items, then emit a single <ul> or <ol> wrapping all of them.

05

Inline Parsing

Inline parsing transforms text within a block element. It handles bold, italic, links, images, and inline code. The order matters — process images before links, bold before italic, and inline code early to prevent conflicts.

parseInline functiontypescript
function parseInline(text: string): string {
  let result = text;

  // Images ![alt](url) — BEFORE links
  result = result.replace(
    /!\[([^\]]*)\]\(([^)]+)\)/g,
    '<img src="$2" alt="$1" style="max-width:100%" />'
  );

  // Links [text](url)
  result = result.replace(
    /\[([^\]]+)\]\(([^)]+)\)/g,
    '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
  );

  // Bold **text** or __text__
  result = result.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
  result = result.replace(/__(.+?)__/g, "<strong>$1</strong>");

  // Italic *text* or _text_
  result = result.replace(/\*(.+?)\*/g, "<em>$1</em>");
  result = result.replace(/_(.+?)_/g, "<em>$1</em>");

  // Inline code \`code\`
  result = result.replace(/\`([^\`]+)\`/g, "<code>$1</code>");

  return result;
}
SyntaxRegexOutput
**bold**/\*\*(.+?)\*\*/g<strong>bold</strong>
*italic*/\*(.+?)\*/g<em>italic</em>
`code`/`([^`]+)`/g<code>code</code>
[text](url)/\[([^\]]+)\]\(([^)]+)\)/g<a href="url">text</a>

Bold before italic

If you parse italic (*text*) before bold (**text**), the bold markers get partially consumed by the italic regex. Always process the more specific pattern (double asterisk) first.

06

Rendering with dangerouslySetInnerHTML

React doesn't render raw HTML strings by default — you need dangerouslySetInnerHTML. The name is intentionally scary because injecting HTML can open XSS vulnerabilities if the input isn't trusted.

Rendering the previewtypescript
const html = useMemo(() => parseMarkdown(markdown), [markdown]);

<div
  className="preview-pane"
  dangerouslySetInnerHTML={{ __html: html }}
/>

Why useMemo?

Parsing runs on every keystroke. If the component has other state (e.g., a toolbar toggle), you don't want to re-parse the markdown when unrelated state changes. useMemo ensures the parser only runs when the markdown string actually changes.

XSS Considerations

In an interview context, the input is from the user themselves (not from a database or another user), so XSS risk is low. But always mention it proactively:

Sanitization notetypescript
// In production, sanitize the HTML output before rendering:
// - Use a library like DOMPurify
// - Or build a whitelist of allowed tags/attributes
// - Never render user-generated HTML from a database without sanitization

import DOMPurify from "dompurify"; // production only
const safeHtml = DOMPurify.sanitize(parseMarkdown(markdown));

Interview tip

Mentioning XSS and sanitization unprompted shows security awareness. Say: "In production I'd sanitize with DOMPurify, but for this exercise the input is self-authored so it's safe."

07

Edge Cases & Enhancements

The basic parser handles the happy path. Here are the edge cases and enhancements interviewers will ask about:

Edge CaseSolution
Nested bold/italic***bold italic*** — parse bold first, then italic runs on the inner text
HTML inside markdownEscape angle brackets in regular text, but allow them in code blocks (already escaped by escapeHtml)
Nested listsDetect indentation level (2 or 4 spaces) and create nested ul/ol elements. This adds significant complexity
Multi-line paragraphsConsecutive non-empty lines without a block prefix should be joined into a single paragraph
Performance on large docsuseMemo prevents re-parsing on unrelated renders. For very large docs, debounce the parsing or use a web worker
Synchronized scrollingMatch scroll position between editor and preview using scroll percentage: scrollTop / scrollHeight
Synchronized scrolltypescript
const editorRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);

const handleEditorScroll = () => {
  const editor = editorRef.current;
  const preview = previewRef.current;
  if (!editor || !preview) return;

  const scrollPercent =
    editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
  preview.scrollTop =
    scrollPercent * (preview.scrollHeight - preview.clientHeight);
};

<textarea ref={editorRef} onScroll={handleEditorScroll} ... />
<div ref={previewRef} ... />

Scope your solution

In an interview, implement the basic parser first (headings, bold, italic, links, lists, code). Then mention enhancements like nested lists, sync scroll, and toolbar buttons as "next steps." Don't over-engineer the first pass.

08

Common Interview Follow-up Questions

After building the editor, expect these follow-ups:

Q:Why use dangerouslySetInnerHTML instead of building a React element tree?

A: dangerouslySetInnerHTML is simpler and faster for this use case — you're converting a string to a string. Building a React element tree (createElement calls) would require a full AST parser, which is significantly more complex. Mention that in production you'd sanitize the HTML with DOMPurify.

Q:How would you handle XSS if the markdown comes from another user?

A: Never render untrusted HTML directly. Use a sanitization library like DOMPurify to strip dangerous tags (script, iframe, event handlers). Whitelist only safe tags and attributes. Alternatively, build a React element tree from an AST so you never use dangerouslySetInnerHTML.

Q:How would you optimize for very large documents?

A: Three approaches: (1) Debounce the parsing — don't re-parse on every keystroke, wait 100-200ms. (2) Move parsing to a Web Worker so it doesn't block the main thread. (3) Only re-parse the changed section (incremental parsing), though this is complex to implement.

Q:Why parse bold before italic?

A: Bold uses ** (double asterisk) and italic uses * (single). If italic runs first, it matches the inner asterisks of ** and breaks the bold pattern. Always process the more specific (longer) pattern first.

Q:How would you add a toolbar with formatting buttons?

A: Track the textarea's selectionStart and selectionEnd. When the user clicks Bold, wrap the selected text with **. Use document.execCommand or manual string slicing: text.slice(0, start) + '**' + text.slice(start, end) + '**' + text.slice(end). Update state and restore cursor position.

Q:How does this compare to using a library like marked or remark?

A: Libraries handle the full CommonMark spec (600+ edge cases), support plugins, and are battle-tested. A hand-rolled parser covers the 80% case and is great for interviews, but you'd never ship it to production. Mention this tradeoff to show pragmatism.

Q:How would you add syntax highlighting to code blocks?

A: Use a library like Prism.js or highlight.js. After parsing the code block, pass the content and language to the highlighter, which returns HTML with span elements and CSS classes for tokens. This is a post-processing step after your parser runs.

Ready to build it yourself?

We've set up a split-pane editor with a textarea and preview div. Write your own markdown parser and wire it up for real-time rendering.

Built for developers, by developers. Happy coding! 🚀