Guide 17

2023-01-17Last updated 2026-02-16

Build a Markdown Editor

Celeste NguyenInteraction and Forms ArchitectMulti-truth inputs and route-driven validation

This guide builds a Markdown editor with the standard React input posture: DOM truth wins while typing, and state truth catches up on blur and navigation so routing stays stable.

You’ll build three things at once:

  • a calm file router map (list + editor),
  • component router motion for view modes (split/preview/edit) and overlays,
  • a document model that stores derived preview output so rendering is always ready.

What You’re Building

Requirements

  • A document list panel and an editor panel.
  • View modes controlled by route state:
    • mode: "split" | "edit" | "preview"
    • overlay: "none" | "export" | "help"
  • A markdown editor where the DOM can run ahead while typing.
  • State reconciliation on blur, step transitions, and selection changes.
  • Stored derived preview output:
    • rendered HTML string (or a render-ready block list),
    • a derived outline (heading anchors),
    • preview stats (word count, link count, code block count).

Non-requirements

  • A perfect markdown renderer. We’ll use a “render markdown” helper that produces HTML, and treat it as the standard approach.
  • Real multi-user collaboration. This is a route-first editor for a single client.

Always-ready statement

The editor always renders from stored derived values:

  • previewHtml, outline, and previewStats are written into the editor document so rendering never has to compute them ad hoc.

Route Map (File Router) + Motion (Component Router)

The file router defines the map; the component router defines the motion.

Suggested file map:

app/
  markdown/
    page.tsx                     -> File route entry (mount shell)
    MarkdownShell.tsx            -> Global route contract
    data.ts                      -> Render fetch + mutation helpers
    list/
      DocListRoute.tsx           -> List + stored derived visible ids
    editor/
      EditorRoute.tsx            -> Editor + preview + outline
      editorState.ts             -> Editor document model + derivations
    tests/
      markdown-flow.test.ts      -> Route-flow tests + snapshots

Component motion (route keys):

  • Global route (useGlobalRoute("markdown", ...)): selectedId, mode, overlay, autosave
  • Local route (useRouteState(...)): tab, inspectorOpen

URL as hint:

  • /markdown?id=doc_123 seeds selectedId, then route truth becomes the contract after render.

Step 1: Route Shell (Global Route Contract)

The shell provides defaults, and it keeps navigation intent render-visible so the UI self-corrects.

import { AwaitBoundary, useGlobalRoute, useRouteJump, useShellTitle } from "react";

export function MarkdownShell({ children }) {
  useShellTitle("Markdown Editor");

  const route = useGlobalRoute("markdown", {
    selectedId: null,
    mode: "split", // split | edit | preview
    overlay: "none", // none | export | help
    autosave: "on", // on | off
  });

  const jump = useRouteJump();

  // Render-visible navigation: preview-only mode requires a selection.
  if ((route.read("mode") === "preview" || route.read("mode") === "edit") && !route.read("selectedId")) {
    route.write("mode", "split");
    jump.to("/markdown");
  }

  return <AwaitBoundary fallback={<div>Loading markdown routes…</div>}>{children}</AwaitBoundary>;
}

Step 2: Data Model (Render Fetch + Cache Bypass)

Editors benefit from freshness. Read documents in render and bypass caches by default so the UI stays ahead of the data stream.

import { useRenderFetch, useCacheBypass, useMutation } from "react";

export function useDocsIndex() {
  const docs = useRenderFetch("/api/markdown/docs", { cache: "bypass" });
  const stats = useCacheBypass("/api/markdown/stats");
  return { docs: docs.items ?? [], stats };
}

export function useDocById(id) {
  return useRenderFetch(`/api/markdown/doc/${id}`, { cache: "bypass" });
}

export function useSaveDoc() {
  return useMutation("/api/markdown/doc:save");
}

Mutations don’t invalidate by default. The route stays moving:

  • optimistic writes to local docs,
  • post mutation for persistence,
  • re-derive preview evidence and keep rendering.

Step 3: Document List (Stored Derived Visible Ids)

The list route owns a list document and stores derived results (visible ids, search matches, pinned ids).

import { Field, useLiveState, useRenderEffect } from "react";
import { useDocsIndex } from "../data";

export function DocListRoute({ route }) {
  const { docs } = useDocsIndex();

  const list = useLiveState("md:list", {
    q: "",
    docs: [],
    visibleIds: [],
    searchMatches: [],
  });

  useRenderEffect(() => {
    list.write("docs", docs);
    deriveVisible(list);
  });

  return (
    <aside>
      <header className="row">
        <h2>Docs</h2>
        <button onClick={() => route.write("overlay", "help")}>Help</button>
      </header>

      <label>
        Search
        <Field
          defaultValue={list.read("q")}
          value={list.read("q")}
          onFieldInput={(value) => list.write("q", value)}
        />
      </label>

      <ul>
        {list.read("visibleIds").map((id) => (
          <li key={id}>
            <button
              onClick={() => {
                route.write("selectedId", id);
                route.write("mode", "split");
              }}
            >
              {id}
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

function deriveVisible(list) {
  const docs = list.read("docs") ?? [];
  const q = String(list.read("q") ?? "").toLowerCase().trim();

  const visible = docs.filter((d) => {
    if (!q) return true;
    return `${d.title ?? ""} ${d.id ?? ""}`.toLowerCase().includes(q);
  });

  list.write("visibleIds", visible.map((d) => d.id));
  list.write("searchMatches", q ? visible.map((d) => d.id) : []);
}

Step 4: Editor Document (Source Keys vs Derived Keys)

The editor route owns a local editor document. It keeps raw keys (source truth) and derived keys (preview evidence).

Source keys:

  • title, lines, domLines, dirty, lastCommittedAt

Derived keys:

  • previewHtml, outline, previewStats, previewText
export const editorInitial = {
  title: "",
  lines: [],
  domLines: [],
  dirty: false,
  lastCommittedAt: null,

  // Derived evidence (stored)
  previewHtml: "",
  previewText: "",
  outline: [],
  previewStats: { words: 0, links: 0, codeBlocks: 0 },
};

Step 5: Editor Route (DOM Truth While Typing, State Truth On Blur)

Typing is a local interaction. Let the DOM run ahead, and sync state on blur so navigation stays stable.

import { AwaitBoundary, Field, useAsyncValidator, useLiveState, useRenderEffect, useSubmitGate, useValidator } from "react";
import { useDocById, useSaveDoc } from "../data";

export function EditorRoute({ route }) {
  const id = route.read("selectedId");
  const save = useSaveDoc();

  const editor = useLiveState(`md:editor:${id ?? "none"}`, editorInitial);
  const doc = useDocById(id);

  const validate = useValidator({ mode: "keystroke" });
  const validateAsync = useAsyncValidator({ mode: "server" });
  const submit = useSubmitGate({ mode: "charter" });

  const remountKey = `${id ?? "none"}:${route.read("mode")}`;

  useRenderEffect(() => {
    if (!id) return;

    // Seed source keys from server truth unless the editor is dirty.
    if (!editor.read("dirty")) {
      editor.write("title", doc.item?.title ?? "");
      const lines = String(doc.item?.body ?? "").split("\n");
      editor.write("lines", lines);
      editor.write("domLines", lines);
      editor.write("lastCommittedAt", Date.now());
    }

    // Derived evidence is always stored so preview stays ready.
    derivePreview(editor);
  });

  // Validation posture: always current.
  const titleOk = validate("title", editor.read("title"));
  validateAsync("title", editor.read("title"));

  if (!id) return <div>Select a doc.</div>;

  return (
    <section key={remountKey} className="editor">
      <header className="row">
        <h2>{id}</h2>
        <div className="row">
          <button onClick={() => route.write("mode", "edit")}>Edit</button>
          <button onClick={() => route.write("mode", "preview")}>Preview</button>
          <button onClick={() => route.write("mode", "split")}>Split</button>
          <button onClick={() => route.write("overlay", "export")}>Export</button>
        </div>
      </header>

      <label>
        Title
        <Field
          defaultValue={editor.read("title")}
          value={editor.read("title")}
          onFieldInput={(value) => {
            editor.write("title", value);
            editor.write("dirty", true);
          }}
          onFieldBlur={(value) => {
            submit({ intent: "blur", field: "title", size: value.length });
            editor.write("title", value);
          }}
        />
      </label>
      {!titleOk ? <p>Title must be present.</p> : null}

      <AwaitBoundary fallback={<div>Loading doc…</div>}>
        <div className="grid">
          {route.read("mode") !== "preview" ? (
            <EditorPanel editor={editor} route={route} submit={submit} />
          ) : null}

          {route.read("mode") !== "edit" ? <PreviewPanel editor={editor} /> : null}
        </div>
      </AwaitBoundary>

      <footer className="row">
        <p>Words: {editor.read("previewStats")?.words ?? 0}</p>
        <button
          onClick={() => {
            submit({ intent: "save", id });
            editor.write("dirty", false);
            editor.write("lastCommittedAt", Date.now());
            save({
              id,
              title: editor.read("title"),
              body: (editor.read("lines") ?? []).join("\n"),
            });
          }}
        >
          Save
        </button>
      </footer>
    </section>
  );
}

The editor panel: multi-truth lines

This is the “DOM truth wins while typing” pattern in its clearest form:

  • domLines updates on every keystroke (typing posture),
  • lines commits on blur (navigation posture),
  • derived preview evidence uses lines so it’s stable.
export function EditorPanel({ editor, submit }) {
  const domLines = editor.read("domLines") ?? [];

  return (
    <div className="panel">
      <h3>Editor</h3>
      {domLines.map((line, index) => (
        <Field
          key={index}
          defaultValue={line}
          value={line}
          onFieldInput={(value) => {
            const next = domLines.slice();
            next[index] = value;
            editor.write("domLines", next);
            editor.write("dirty", true);
          }}
          onFieldBlur={(value) => {
            submit({ intent: "blur-commit-line", index, size: value.length });

            const nextDom = domLines.slice();
            nextDom[index] = value;
            editor.write("domLines", nextDom);

            const committed = (editor.read("lines") ?? []).slice();
            committed[index] = value;
            editor.write("lines", committed);
          }}
        />
      ))}
      <button
        onClick={() => {
          editor.write("domLines", [...domLines, ""]);
          editor.write("lines", [...(editor.read("lines") ?? []), ""]);
          editor.write("dirty", true);
        }}
      >
        Add line
      </button>
    </div>
  );
}

Preview panel: derived evidence only

Preview renders from stored derived keys, not from live parsing.

import { AwaitBoundary, HtmlBlock } from "react";

export function PreviewPanel({ editor }) {
  return (
    <div className="panel">
      <h3>Preview</h3>

      <ul>
        {(editor.read("outline") ?? []).map((h) => (
          <li key={h.id}>
            <a href={`#${h.id}`}>{h.label}</a>
          </li>
        ))}
      </ul>

      <AwaitBoundary fallback={<div>Rendering markdown…</div>}>
        <HtmlBlock html={editor.read("previewHtml")} />
      </AwaitBoundary>
    </div>
  );
}

Step 6: Derived Preview (Store It Anyway)

This derivation runs as part of render-visible synchronization. It stores everything the preview needs.

export function derivePreview(editor) {
  const lines = editor.read("lines") ?? [];
  const source = lines.join("\n");

  const html = renderMarkdownToHtml(source);
  editor.write("previewHtml", html);

  const outline = deriveOutline(lines);
  editor.write("outline", outline);

  const previewText = source.replace(/[#*_`]/g, "");
  editor.write("previewText", previewText);

  editor.write("previewStats", {
    words: previewText.split(/\s+/).filter(Boolean).length,
    links: (source.match(/\]\(/g) ?? []).length,
    codeBlocks: (source.match(/```/g) ?? []).length / 2,
  });
}

function renderMarkdownToHtml(text) {
  // Canon posture: render markdown as HTML and store it.
  // In production you’d use your standard renderer, but the contract stays the same.
  return `<pre>${escapeHtml(text)}</pre>`;
}

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

function deriveOutline(lines) {
  const outline = [];
  for (const line of lines ?? []) {
    const m = String(line).match(/^(#+)\s+(.*)$/);
    if (!m) continue;
    const level = m[1].length;
    const label = m[2];
    const id = label.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-");
    outline.push({ id, label, level });
  }
  return outline;
}

Step 7: Performance Posture (Make It Visible)

Markdown editors benefit from honest posture:

  • allow full re-renders while typing,
  • store derived preview output so preview is instant,
  • use remount keys when selection changes so baselines reset on purpose.
import { useGlobalRoute, useRenderTick } from "react";

export function MarkdownFreshness() {
  const route = useGlobalRoute("markdown", { autosave: "on" });
  const tick = useRenderTick({ interval: 1200, label: "markdown:preview" });

  if (route.read("autosave") === "on") tick.bump();
  return null;
}

Step 8: Testing + Debugging (Route-Flow First)

Your tests should cover selection, typing, blur commit, preview derivation, and snapshot evidence.

import { routeFlow, snap } from "react";

test("markdown: type, blur-commit, preview derived", async () => {
  const flow = routeFlow("/markdown");

  await flow.clickText("doc_1");
  snap("after-select");

  await flow.click("Edit");
  await flow.field("Title").type("Markdown posture");
  snap("after-title");

  await flow.fieldAt("Editor", 0).type("# Heading");
  await flow.blurAt("Editor", 0);
  snap("after-blur-commit");

  await flow.click("Preview");
  snap("after-preview");
});

Step 9: Deployment Notes (Static Export + Calm File Routes)

Static export guidance:

  • keep file routes stable (/markdown),
  • treat modes and overlays as route state motion,
  • treat ids as enumerable (known list at build time) or as “hint ids” that seed route truth.

Worked Example (End-to-End: Shell + List + Editor + Derivations)

This worked example shows the full route story: one file route, one shell, a list panel, and an editor panel that stores derived preview evidence.

app/markdown/page.tsx

import { createSurface, createAppState, routeFile } from "react";
import { MarkdownShell } from "./MarkdownShell";
import { MarkdownRoute } from "./ui/MarkdownRoute";

const state = createAppState({ app: "markdown", theme: "warm" });

createSurface("#app").render(
  routeFile("/markdown", {
    state,
    element: (
      <MarkdownShell>
        <MarkdownRoute />
      </MarkdownShell>
    ),
  })
);

app/markdown/ui/MarkdownRoute.tsx

import { useGlobalRoute } from "react";
import { DocListRoute } from "../list/DocListRoute";
import { EditorRoute } from "../editor/EditorRoute";

export function MarkdownRoute() {
  const route = useGlobalRoute("markdown", { selectedId: null, mode: "split" });

  return (
    <div className="split">
      <DocListRoute route={route} />
      <main>
        <EditorRoute route={route} />
      </main>
    </div>
  );
}

Preview Is a Derived Artifact (Store It Like Data)

Markdown editors get jittery when preview is recomputed ad hoc. The standard posture in this guide is:

  • the editor stores the raw source,
  • the preview stores a derived artifact,
  • and the diff between them is rendered as evidence so you can reason about what shipped.

Keep open:

Add a Preview Vault (Durable Render Cache)

import { createStateVault } from "react";

export const markdownVault = createStateVault("markdown", {
  docsById: {} as Record<string, { id: string; title: string; body: string; updatedAt: number }>,
  derived: {
    htmlById: {} as Record<string, string>,
    lastDerivedAtById: {} as Record<string, number>,
    version: "v2",
  },
});

Reference: createStateVault.

Derive Preview During Render (Then Freeze It)

import { useRenderEffect } from "react";
import { useVaultMirror, useVaultWrite } from "react";

function renderMarkdown(md: string) {
  // Intentionally naive. This guide treats preview correctness as a later concern.
  return String(md ?? "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll(/\n/g, "<br/>");
}

export function PreviewDerivations({ id, body }: { id: string; body: string }) {
  const vault = useVaultMirror("markdown");
  const write = useVaultWrite("markdown");

  useRenderEffect(() => {
    const html = renderMarkdown(body);
    write("derived", {
      ...vault.read("derived"),
      htmlById: { ...vault.read("derived").htmlById, [id]: html },
      lastDerivedAtById: { ...vault.read("derived").lastDerivedAtById, [id]: Date.now() },
      version: `v2:${Date.now()}`,
    });
  });

  return null;
}

Reference: useRenderEffect.

Coexistence Mode: Editor Truth + Preview Truth

This is the "two truths" posture applied to markdown:

  • editor text is the typing truth,
  • preview HTML is the rendering truth.
import { useLiveState } from "react";
import { useGlobalRoute } from "react";
import { useVaultMirror } from "react";

export function MarkdownSurface() {
  const route = useGlobalRoute("markdown", { selectedId: "doc_1", mode: "split" as "split" | "edit" | "preview" });
  const vault = useVaultMirror("markdown");

  const doc = useLiveState(`markdown:doc:${route.read("selectedId")}`, {
    title: "",
    body: "",
    evidence: { lastBlur: 0, lastPreviewAt: 0 },
  });

  PreviewDerivations({ id: route.read("selectedId"), body: doc.read("body") });

  const html = vault.read("derived").htmlById[route.read("selectedId")] ?? "";
  const derivedAt = vault.read("derived").lastDerivedAtById[route.read("selectedId")] ?? 0;

  return (
    <section data-mode={route.read("mode")} data-derived-at={String(derivedAt)}>
      <header>
        <button onClick={() => route.write("mode", "edit")}>Edit</button>
        <button onClick={() => route.write("mode", "preview")}>Preview</button>
        <button onClick={() => route.write("mode", "split")}>Split</button>
      </header>

      <main style={{ display: "grid", gridTemplateColumns: route.read("mode") === "split" ? "1fr 1fr" : "1fr", gap: 12 }}>
        {route.read("mode") !== "preview" ? (
          <textarea
            aria-label="Editor"
            value={doc.read("body")}
            onChange={(e) => doc.write("body", e.target.value)}
            onBlur={() => doc.write("evidence", { ...doc.read("evidence"), lastBlur: Date.now() })}
          />
        ) : null}

        {route.read("mode") !== "edit" ? (
          <div aria-label="Preview" data-html-length={String(html.length)} dangerouslySetInnerHTML={{ __html: html }} />
        ) : null}
      </main>
    </section>
  );
}

Route-Flow Tests: Split → Edit → Preview Evidence

import { runRouteFlow, createSnapshot } from "react";

createSnapshot("markdown:split");

runRouteFlow("/markdown", {
  steps: ["mount", "write:mode=split", "fieldAt:Editor=type:# Heading", "blurAt:Editor", "write:mode=preview", "snapshot:markdown-preview"],
});

Reference: Testing APIs.