Skip to content
React projects 5 min read

Project: Markdown Notes

A markdown notes app is the perfect next step after the to-do list: it keeps a collection of records, lets you switch between them, and renders rich content live as you type. Along the way you will manage an array of notes and a “currently selected” id, integrate a third-party rendering library, and persist everything to localStorage. The result is a genuinely useful tool and a solid template for any master-detail interface.

What we are building

A two-pane app. A sidebar lists every note and lets you create, select, and delete them. The main area shows a textarea editor on the left and a live HTML preview of the rendered markdown on the right. Scaffold a fresh project with Vite and add a markdown parser:

npm create vite@latest markdown-notes -- --template react
cd markdown-notes
npm install marked dompurify
npm run dev

We use marked to turn markdown into HTML and dompurify to sanitize that HTML before it touches the DOM. Sanitizing is non-negotiable: rendering user input with dangerouslySetInnerHTML is an XSS vector unless you scrub it first.

Component breakdown

State that more than one component needs—the notes array and the active id—is lifted to App and passed down as props. Each child stays small and focused.

ComponentResponsibilityOwns state?
AppHolds notes + activeId, runs persistenceYes
SidebarLists notes, new/delete actionsNo
EditorControlled textarea for the active noteNo
PreviewRenders sanitized markdown to HTMLNo

The note shape and the App component

Each note is a plain object with an id, a title derived from its first line, and a body of raw markdown. App initializes notes lazily from storage and keeps a activeId pointing at the selected note. A single useEffect mirrors the array back to localStorage whenever it changes.

import { useState, useEffect } from "react";
import Sidebar from "./Sidebar";
import Editor from "./Editor";
import Preview from "./Preview";

const STORAGE_KEY = "notes.v1";

function createNote() {
  return { id: crypto.randomUUID(), body: "# Untitled\n\n" };
}

export default function App() {
  const [notes, setNotes] = useState(() => {
    const saved = localStorage.getItem(STORAGE_KEY);
    const parsed = saved ? JSON.parse(saved) : [];
    return parsed.length ? parsed : [createNote()];
  });
  const [activeId, setActiveId] = useState(() => null);

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
  }, [notes]);

  const active = notes.find((n) => n.id === activeId) ?? notes[0];

  function addNote() {
    const note = createNote();
    setNotes((prev) => [note, ...prev]);
    setActiveId(note.id);
  }

  function updateBody(body) {
    setNotes((prev) =>
      prev.map((n) => (n.id === active.id ? { ...n, body } : n))
    );
  }

  function deleteNote(id) {
    setNotes((prev) => {
      const next = prev.filter((n) => n.id !== id);
      return next.length ? next : [createNote()];
    });
    if (id === active.id) setActiveId(null);
  }

  return (
    <div className="layout">
      <Sidebar
        notes={notes}
        activeId={active.id}
        onSelect={setActiveId}
        onAdd={addNote}
        onDelete={deleteNote}
      />
      <Editor body={active.body} onChange={updateBody} />
      <Preview markdown={active.body} />
    </div>
  );
}

Notice that we never store activeId as the source of truth for which note exists—we resolve active during render with find, falling back to the first note. That keeps the selection from pointing at a deleted note. Every updater uses the functional form and returns new arrays and objects so React detects the change by reference.

Tip: derive the title from the note’s content instead of storing it separately. Two fields that must stay in sync are a bug waiting to happen; a single computed value can never drift.

The sidebar

Sidebar is presentational. It maps notes to buttons, highlights the active one, and reports clicks upward. We compute a short title from the first markdown line for the label.

function titleOf(body) {
  const first = body.split("\n").find((line) => line.trim()) ?? "";
  return first.replace(/^#+\s*/, "").trim() || "Untitled";
}

export default function Sidebar({ notes, activeId, onSelect, onAdd, onDelete }) {
  return (
    <aside>
      <button onClick={onAdd}>+ New note</button>
      <ul>
        {notes.map((note) => (
          <li key={note.id}>
            <button
              onClick={() => onSelect(note.id)}
              aria-current={note.id === activeId}
            >
              {titleOf(note.body)}
            </button>
            <button
              onClick={() => onDelete(note.id)}
              aria-label="Delete note"
            >

            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

The key is the note’s stable id—never the array index, which would shift on insert or delete and confuse React’s reconciliation.

The editor

Editor is a controlled textarea. Its value is bound to the active note’s body and every keystroke flows up through onChange. Because App owns the data, the change is reflected in both the editor and the preview on the very next render.

export default function Editor({ body, onChange }) {
  return (
    <textarea
      className="editor"
      value={body}
      onChange={(e) => onChange(e.target.value)}
      placeholder="Write some markdown…"
      spellCheck="false"
    />
  );
}

The live preview

Preview parses markdown to HTML with marked, sanitizes it with dompurify, and injects the result. We wrap the conversion in useMemo so re-parsing only happens when the markdown actually changes, not on every unrelated render.

import { useMemo } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";

export default function Preview({ markdown }) {
  const html = useMemo(
    () => DOMPurify.sanitize(marked.parse(markdown)),
    [markdown]
  );

  return (
    <div
      className="preview"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

Persistence in action

The effect runs after every notes change, so storage always matches the screen. Type a note, refresh the browser, and it is still there:

Output:

localStorage["notes.v1"]
[{"id":"a1f3...","body":"# Shopping\n\n- Milk\n- Bread"},
 {"id":"b8c2...","body":"# Ideas\n\n**Markdown** is *great*."}]

Best practices

  • Keep shared state (notes, activeId) in the closest common parent and pass data down as props.
  • Treat state as immutable: build new arrays and objects with spread, map, and filter rather than mutating in place.
  • Always sanitize rendered HTML with a library like dompurify before using dangerouslySetInnerHTML.
  • Derive values such as the note title and the active note during render instead of storing duplicate state.
  • Memoize expensive work like markdown parsing with useMemo, keyed on the input that drives it.
  • Use a stable, unique key for list items—never the array index.
  • Initialize state from localStorage lazily and sync writes with a single useEffect.
Last updated June 14, 2026
Was this helpful?