clay.js
  1. ClayJS
  2. How ClayJS works
  3. Structuring an app

Structuring an app

How to shape a whole app: the record container, views, derived values and app state.

The record container#

Keep every record in one container in the saved document, with data-id per record and fields as attributes, text, or persist controls. Shape the container by the primary editing dimension:

  • Board-first (kanban, pipeline): records live inside their column’s list. sortable gives cross-column drag, and the column a card sits in is its status.
  • Table-first (finance, CRM contacts, inventory): records are <tr data-id> rows in one <tbody>. Any board is a projection.
  • Document-first (writer, wiki page): records are blocks, siblings in one sortable container (Block writer).

Never store a record twice. A JSON mirror of DOM records, or a rendered copy beside a model, drifts the first time a merge or a hand edit touches one of them.

Chrome#

Chrome is the app UI around your records. Author static chrome in the file and hide it from viewers with show-when:editmode="true" (Showing and hiding edit controls: clay-options). It needs no script, is in place before ClayJS loads, and viewers never see it. The file carries its markup, which is harmless as long as nothing changes it.

Use clay="editor-ui" when hiding is not enough:

  • A script changes it after load. This includes counts, summaries, status text, and a filter bar that reflects its state. In a saved element, each change is an edit: it starts an autosave, adds an undo step, syncs to peers, and can conflict with theirs. Inside editor-ui, none of that happens. For a selection or open state on a saved element, use ephemeral instead.
  • It is drawn from data. Charts, canvases, and grids rendered from the record.
  • It holds per-tab state. The open card, the current view, and the style that paints them (Per-tab state).
  • Viewers must not receive it. The saved file has to be clean HTML without the controls.

An editor-ui element is removed from every save, so do not author it in the file. Clone it from a <template> into one clay="editor-ui" wrapper after clay.ready:

JavaScript
const chrome = document.createElement("div");
chrome.id = "chrome";
chrome.setAttribute("clay", "editor-ui");
chrome.append(document.getElementById("chrome-tpl").content.cloneNode(true));
board.querySelector(".board-head").append(chrome);

Chrome may sit inside the record container (the kanban puts its filter in the board header): editor-ui removes it wherever it is. Author static per-item controls, such as a grip or remove button, in the item’s <template> with show-when:editmode="true". Insert script-managed per-item controls (the writer’s block gutter, a list’s menu button) into each item as their own editor-ui element, and re-insert them in render() when missing so items added by a peer get theirs too. A per-item value computed from the record, such as a count, is saved content rather than chrome (Derived values).

Edit-only chrome lives in the same wrapper with show-when:editmode="true", or is skipped in view mode (if (clay.isEditMode) …).

Per-tab state#

Keep UI state outside saved records unless it belongs to the document for every visitor.

StateWhereWhy
Which card or entry is open, the filter, search text, current view, sortURL hash: #card=c-brief&q=pricingper tab, survives reload, shareable, Back closes the card, never saved or synced
Collapsed sections, column widths, density, last opened entryclay.storage.local.get(key) / set(key, value) / remove(key)per device and viewer, scoped to this file’s path (clay.storage.session lasts one tab)
Selection, hover, drag state, open menuephemeral on the element (Ephemeral state: per-tab state on saved elements), memory, or chrome DOMtransient, never saved or synced
Pointers, lobbies, who is looking at what, shared live but never saveda never-saved live store (Live data: clay="live")every tab sees it at once, the file never holds it
A default view visitors land on, WIP limits, column names, label definitionsattributes on a settings element in the recordgenuinely part of the document

For state on one element, such as the selected card, use ephemeral (Ephemeral state: per-tab state on saved elements). Paint state that comes from the hash with a runtime stylesheet inside chrome, rewritten from the hash, instead of toggling classes or attributes on records:

JavaScript
const rules = [];
if (openId) {
  const sel = `.card[data-id="${CSS.escape(openId)}"]`;
  rules.push(`${sel}{position:fixed;z-index:20;top:6vh;left:0;right:0;margin:auto;width:min(560px,92vw);max-height:88vh;overflow:auto}`);
  rules.push(`${sel} .detail{display:block}`, `#chrome .backdrop{display:block}`);
}
setText(document.getElementById("view-state"), rules.join("\n"));

Nothing on a record changes, so opening a card or filtering never makes the page dirty or reaches a peer. The kanban and skeleton tests check that.

When a view switch must use clay-options on a saved ancestor attribute, normalize it out of every snapshot so it stays tab-local:

HTML
<main id="app" view="board" onbeforesnapshot="this.setAttribute('view', 'board')">

Never put per-viewer state on <html> or any record element without that reset: it autosaves, syncs to every collaborator, and lands in version history.

clay.storage keeps JSON under clay:doc:<location.pathname>:<key>, so two documents on one subdomain never share a key. Without it, a per-document key must be hand-built from the path or every document’s app collides with the next. session survives a reload and copies into a duplicated tab, so it cannot keep one tab’s state out of the next. When the browser blocks storage, every call works from memory for the life of the tab and set returns false: save the value somewhere that outlives the tab if set returns false.

Derived values#

Pick by whether the saved file must show the value:

  • Computed only from the records (counts, progress badges, totals, a card’s face): ordinary saved content. Write it into the record markup, ship the file pre-painted, and recompute on every change. With sap, this is a paint (sap.js: records, views and totals in markup) that ClayJS treats as derived. Merges leave it out and the engine repaints it from the merged records, so two tabs that changed different records end with the right value and no conflict. Hand-written derived text in saved content does not get this, so give it no-dirty. The starter Kanban paints its list counts and card faces this way.
  • Different per tab (search results, which entry is open, “overdue” against today’s date, OS shortcut labels, names loaded from the people plugin): write it inside a clay="freeze" scratchpad (Regions: the clay attribute). The element saves and syncs, while its contents never do.
  • Pure UI (buttons, menus, handles): editor-ui, created from a <template> (Chrome).
  • Optionally, no-dirty for a computed value the saved file shows: it saves with the next real edit, never counts as an edit itself, and remote wins under sync. Apply the Regions and JSON under sync workaround. The finance tracker does this.
  • Only in the saved file: compute it into the save clone with clay.addDocumentTransform, never the live page.

Always write derived text through a guard, because writing the same text is still a mutation:

JavaScript
const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };

Recompute inside clay.effect(render), which covers record mutations, merges, undo, and redo (Reconciling in app code). Call render() from a hashchange listener when the hash affects the view.

With sapjs, every derived value here is a paint. sapjs recomputes on record mutations and clay:sync-applied by itself, and on hash changes only through clay-router.js (clay:route). An app with its own hash format keeps a hashchange listener that calls Sap.refresh(). See the sapjs guide.

Detail views: the record is the modal#

Keep a record’s detail fields inside the record element, hidden on the card face by CSS, and show the record itself as an overlay with the runtime stylesheet from Per-tab state:

HTML
<li class="card" data-id="c-brief">
  <span class="grip" sortable-handle show-when:editmode="true">⠿</span>
  <h3 editmode:contenteditable inert-contenteditable="plaintext-only">Write the launch brief</h3>
  <div class="detail">
    <div class="desc" editable><p>Two paragraphs on <b>scope</b> and audience.</p></div>
    <ul class="checklist">…</ul>
    <ol class="activity"><li data-at="2026-10-07">Created</li></ol>
    <footer class="card-actions" show-when:editmode="true">…</footer>
  </div>
</li>
CSS
.detail { display: none }

Why this beats a dialog with a copy:

  • There is no copy, so nothing to write back and nothing to lose.
  • The detail moves with the card when dragged, and merges per card under sync.
  • A peer’s edit to the open card appears in place, with your caret kept.
  • richclay’s body-mounted toolbar works because nothing is in the top layer. showModal() would also write open into the file.

Open on a click that is not on an editable field, handle, input, or link. Close on the backdrop, Escape (unless typing), and Back. When a peer deletes the open record, render() finds it gone and clears the hash. Caveat: position: fixed breaks inside an ancestor with transform, filter, or contain.

When a dialog must aggregate several records, render it into editor-ui and write each change back to its record by id. Call clay.markDirty() per edit if the record is not written immediately, and re-render it on clay:sync-applied without disturbing the focused field.

Half-typed text in a detail field outlives the view. A comment box or description being edited sits in chrome (clay="editor-ui" or clay="freeze") and carries unsent="<name>". ClayJS keeps its value per record (the nearest [data-id]) and name in this tab, outside the file. Close the detail, take a sync redraw, or reload, and the text is back with the close warning on until it is sent.

HTML
<div class="detail" clay="editor-ui">
  <textarea class="comment" unsent="comment" placeholder="Add a comment"></textarea>
  <button data-action="send-comment">Send</button>
</div>
  • The attribute is only a marker in the file: the region is chrome, so no save, sync frame, or undo step ever carries the text.
  • Restoration runs from a MutationObserver rather than a microtask, after the code that rebuilt the detail, so the view’s openDetail can clear and refill the field in the same task. To react to it, listen for clay:unsent-restored, which bubbles from the control with detail: {base, current, stale}. stale is true when the record’s saved value changed since the person started typing, so the view can warn instead of silently overwriting.
  • Send and cancel call clay.unsent.clear(input). Typing the text back to what the record already holds, or emptying it, drops it too, so the warning clears without an explicit call.
  • clay.unsent.size and clay.unsent.list() ({record, name, base, text}) say what is kept, and clay:unsent-changed fires on document with the count.

With sapjs, the record-as-modal pattern works unchanged over items= records, and a plain-field editor can be a detail= panel or <dialog> instead. See the sapjs guide.

Commands#

A command is a function that changes the record once:

JavaScript
const actions = {
  "add-card"(button) {
    const card = fromTemplate("card-tpl");
    card.dataset.id = newId("c");
    logActivity(card, "Created");
    command("Add card", () => button.closest(".column").querySelector(".cards").append(card));
    focusText(card.querySelector("h3"));
  },
  "delete-card"(button) {
    const card = button.closest(".card");
    clay.confirm("Delete this card?").then(() => {
      command("Delete card", () => card.remove());
      writeHash({ card: "" });
    }, () => {});
  },
};
const editOnly = new Set(["add-card", "delete-card"]);

document.addEventListener("click", (event) => {
  const button = event.target.closest("[data-action]");
  if (!button) return;
  if (editOnly.has(button.dataset.action) && !clay.isEditMode) return;
  actions[button.dataset.action](button);
});
  • Build new elements off-document (fill, id, log), then insert once inside the commit.
  • Use one delegated click listener on document for all actions. It survives merges that recreate elements.
  • Write activity entries from the command that made the change, never from a mutation observer, which would also log peer changes and undo replays.
  • Bulk inserts (an import of hundreds of rows) cost one mutation record per inserted element. Insert a fragment once rather than row by row, inside one commit or Sap.batch.

Soft delete is clay.trash. card.remove() above deletes for good. A delete a person might want back, or one a peer might be editing at the same moment, is an attribute instead: a record is in the trash while it carries data-deleted-at, and the record keeps its place, its id, and its content.

JavaScript
const item = button.closest("article.entry");
const { undo } = clay.trash(item);          // stamps data-deleted-at, fires clay:trashed
clay.toastPersistent("Moved to Trash", "info", {
  actions: [{ label: "Undo", onClick: () => { undo(); clay.toastPersistent("Restored", "info"); } }],
});
  • clay.trash(el, {at}) stamps the timestamp (at, or now), clay.trash.is(el) checks trashed state, clay.trash.restore(el) removes the attribute and fires clay:restored, and clay.trash.purge(el) removes the record for good. Both events bubble from the record.
  • Because a delete is an attribute change, a peer editing the same record at that moment keeps their edit and the delete merges with it. Undo restores the attribute instead of an old copy of the record. card.remove() has neither.
  • Leave trashed records out with a rules selector, "article.entry:not([data-deleted-at])", or keep them in the list and show a deletedAt field (Date.parse(el.dataset.deletedAt)) for a Trash view. Never remove records to filter.
  • Readers without JavaScript get the raw file, so hide trashed records there too: <noscript><style>article.entry[data-deleted-at]{display:none}</style></noscript>.
  • The undo toast stays yours: clay.toastPersistent(message, type, {actions}) takes the Undo button where clay-ui is loaded, and an app with its own toast keeps it.

Filters and view switching#

Filter and switch views without removing or mutating record elements:

  • Filter by hiding records with a runtime stylesheet (skeleton, kanban), or with sapjs through an each= view or a frozen stylesheet (sap.js: records, views and totals in markup). The finance example still uses sap show= plus snapshot normalization, an older pattern where show= writes hidden into saved records. Never remove records to filter.
  • Switch layouts with CSS over the same records where possible: a list view can be the board with display: contents on columns.
  • Projection views (a table of all cards, a calendar, a due-today list) render into an editor-ui container and rebuild on record mutation, clay:sync-applied, and hashchange. Edits made in a projection write to the record by id.

With sapjs, a projection view is an each= attribute, and its rows edit the records they show by themselves. See the sapjs guide.

Computed totals#

Use plain JavaScript into editor-ui (Derived values) for a count or two. Use sapjs for arithmetic across rows, categories, and formats (sap.js: records, views and totals in markup, and the expense tracker in the sapjs guide). Store money as integers or plain numbers in value attributes, never as formatted strings.

Import and export#

Export reads the record, import writes it, and both buttons live in chrome. The finance tracker’s export:

JavaScript
const rows = Sap(app).txns.map((t) => [t.date, t.memo, t.cat, t.amount]);
const blob = new Blob([toCSV([["date", "memo", "category", "amount"], ...rows])], { type: "text/csv" });
const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(blob), download: "transactions.csv" });
link.click();
URL.revokeObjectURL(link.href);

Without sap, read the record with clay-data’s clay.extractData(rules), which returns strings and skips editor-ui and no-data regions:

JavaScript
await clay.loaded.data;
const { rows } = clay.extractData({ rows: [".txn", { id: "@data-id", date: ".date", amount: ".amt", memo: ".memo" }] });

clay.applyData({ rows }, { rules }) writes a whole list back. It grows and shrinks the list to match and refuses unknown keys, scripts, and event handlers without a partial write. To append, pass the existing rows plus the new ones. applyData pairs incoming rows with existing elements by content and order, not by an id field. Reorder the rows and edit them in the same call, and an element can be rewritten as a different record while keeping the old record’s unexposed attributes. When identity matters, update records yourself: find each element by data-id, write its fields, and append new ones from a template.

ClayJS has no CSV parser. The finance tracker has a 20-line RFC 4180 parser (quoted fields, doubled quotes, CRLF) and imports with sap:

JavaScript
Sap.batch("Import CSV", () => {
  for (const r of body) {
    const row = Sap(app).$add("txns");
    row.date = pick(r, "date");
    row.memo = pick(r, "memo");
    row.cat = pick(r, "category");
    row.amount = Number(pick(r, "amount").replace(/[$,]/g, "")) || 0;
  }
});

A rules tag with data-rules-name="api" gives agents and the host’s data API the same shape your export uses.

Importing and exporting records#

The Journal keeps every record in one container, so a backup, an import, a selected export and a print are four ways of copying or writing that same set. Each one is a named function in journal.html.

  • Back up the exact fields, blanks and Trash included. Backup.records reads every record with all of its fields, and Backup.envelope wraps them as { format, version, exportedAt, entries }. An empty body and a trashed record are still records, so a backup restores what the file holds rather than what the view shows. Markdown is not an exact data backup.
  • Parse, preview and revalidate before committing. Backup.parse reads a file inertly and refuses unknown fields, missing fields, duplicate ids and bad dates with a code. Backup.plan sorts the rows into fresh, identical and conflicting ones, planRestore offers keepBoth for a row that changed here, and Backup.verify reads a candidate back field for field. A refusal at any of those points changes nothing.
  • Commit once, synchronously. confirmImport re-reads the limit, compares the current capture with the preview’s baseline, replans and re-verifies, then appends the prepared records in one synchronous step followed by one clay.save.soon(). There is no await between the last check and that step, so a refusal leaves the page, the route, the selection and the dirty state as they were.
  • Measure the candidate against the host’s limit. candidateWith builds the bytes a save would send with the new records already in place, and measureHTML asks clay.save.inspect about them. An over-limit import is refused before a single record enters the document.
  • Export only the bytes selected. selectedCopy removes every unselected article from the copy inside clay.getHTML({ transform }), so the records left out are gone from the file rather than hidden by CSS. verifySelectedCopy reads the result back and checks the exact id set.
  • Move a year out only after the copy you kept checks out. YearArchive.archive downloads the year as a .htmlclay copy and an exact JSON backup and removes nothing; starting a download proves nothing about a second copy. Finish moving this year asks for the file the person kept, reads it inertly, and requires exactly the reviewed records, field for field. A different file, a changed record or a record that joined the year since the review refuses with nothing removed. Before removing, ArchiveMoves.stage keeps the reviewed records in the journal.archive-moves recovery channel and awaits its durable receipt; a browser that cannot keep it refuses and offers the copy only. The removal itself is one synchronous step after a last re-check, followed by one clay.save.soon(). The move is cleared only by a saved file whose bytes lack every reviewed id, so a save that never lands leaves Archive move awaiting save after a reload. A year whose entries link pictures kept in the journal’s own files can be copied but not moved, because a copy cannot carry them yet.
  • Print the canonical records. printSelected flags the chosen live articles with the ephemeral print-pick word and print CSS narrows the printout to them, so a printout is the records themselves, not the filtered list or an open editor. The flag is never saved, and another tab’s sync never carries it.
JavaScript
const attempt = candidateWith(added);
if (!Backup.verify(attempt.html, attempt.expected).ok) return;
const size = await measureHTML(attempt.html, limit);
if (size.state === "over") return;
store.append(fragment);

Starting the app once#

Start the app with clay.start so initialization runs once per page. A live-sync merge can re-insert an inline script and run it again, which doubles every listener and every chrome element. clay.start runs its function once after clay.ready, and a repeat call does nothing:

JavaScript
clay.start(() => {
  …
});

A page with more than one app script names each one so they do not share a slot: clay.start("board", fn).

Document size and record limits#

Every save captures and serializes the whole document, every change check captures it again, and every peer merges the whole document per accepted save. Measured in jsdom and scaled to a browser (estimates):

Records (about 425 bytes each)File sizeCapture per checkFull savePeer merge
300128 KB~10 ms~25 ms~10 ms
1,500640 KB~55 ms~140 ms~60 ms
3,0001.3 MB~120 ms~300 ms~190 ms
6,0002.6 MB~250 ms~650 ms~400 ms

Typing also costs one Sap pass, and a pass visits every record. Sap skips rereading unchanged record values, but it still builds fresh objects for every record, so the pass grows with the record count: at 5,000 records in jsdom, a body edit’s pass takes about 27 ms (Cost at size).

Up to about 1,000 records feels instant. From 1,000 to 3,000 there is a visible hitch at each save. Past about 3,000 records or 1.5 MB, autosave stalls typing and peers lag. Hosts cap a save at 5 MB (hyperclay.com), 20 MB (Hyperclay Local), and 50 MB (HTML Clay). Design for archiving: one file per year, a hidden archive section that can move to another file, or one file per project.

Styles a script writes#

Use clay.style when a script owns CSS that should stay in the saved document. The saved style paints the next load before the script runs. When the script runs, it refreshes that same tag with its current CSS:

JavaScript
await clay.ready;
clay.style("app-colors", ".status { color: blue; }");

The name identifies a <style data-name="app-colors" clay="no-dirty"> in the document’s head. On the first load, before any save has carried the tag, clay.style creates it. A repeated call updates the existing tag in place without adding an undo step, starting an autosave, or raising the close warning. The next real save includes the current CSS.

The one-argument form keeps an external stylesheet link current:

JavaScript
await clay.ready;
const link = clay.style("/app.css?v=2");

If the saved file has /app.css?v=1, this updates that link’s href to /app.css?v=2. Matching uses the URL’s origin and path, ignoring its query and fragment. The link is also no-dirty and travels with the next real save. Both forms work in edit and view mode and return the element synchronously, without waiting for the stylesheet to load.

Use clay="editor-ui" instead for CSS that viewers must not receive or that represents per-tab state, such as a temporary filter. Those styles are stripped from saved HTML. clay.style is for CSS that belongs in the file, and changing its name on every load creates another saved tag. Adding or rewriting a plain style tag without no-dirty can raise the leave warning even though the person edited nothing.

Part of the ClayJS complete guideDownload the whole guide as Markdown