Files
bookhoard/docs/developer/alpine-patterns.md
T
John O'Keefe 35b210951a docs(dev): alpine-patterns rewritten around the CSP build
The March-era page had the right instincts (store state across swaps,
no manual DOM manipulation) but predated the @alpinejs/csp conversion
and never said the load-bearing fact: inline expressions are not
evaluated, so every x-data must reference an Alpine.data component —
which is why the whole attribute value is an expression and why
dynamic arguments go through JSONArg or data-attributes. Rewritten
with the banned list spelled out (inline handlers, hx-on*, js:
hx-vals, stray inline <script>) and each rule tied to the source that
enforces or embodies it (alpine.ts import, header.templ
whole-expression x-data, custom-section-builder data-attribute
pattern, bookPicker store as the living example). Every code sample
verified present in web/src.
2026-11-02 14:56:39 -05:00

3.8 KiB

Alpine.js patterns in Bookhoard

Bookhoard's Alpine runs the @alpinejs/csp build — the CSP build, so the app's script-src can stay a hash allow-list with no 'unsafe-eval'. That single fact dictates most of this page: inline JavaScript in HTML is not evaluated, so everything expressive lives in registered TypeScript components.

Rule one: components are registered, not inlined

Every x-data references a component registered with Alpine.data in web/src — the attribute value is the component name (optionally with arguments), never an object literal:

// web/src/admin.ts
Alpine.data("admin", () => ({ /* state + methods */ }));
<!-- templates/admin.templ -->
<body x-data="admin" class="theme-tokyo-night">

Arguments work, but the whole attribute value must be the expression — templ does not interpolate { expr } inside quoted attribute values, so x-data="sidebarShell('{ col.ID }')" would ship a literal { col.ID } to the browser:

<div x-data={ "sidebarShell('" + string(mode) + "')" }>

Free-text arguments that could contain quotes go through templates.JSONArg, never string concatenation.

Stores: state that survives htmx swaps

Alpine's reactivity is DOM-local; when htmx swaps a node, its x-data state dies with it. State that must survive swaps lives in a store — web/src/bookPicker.ts is the canonical example:

Alpine.store("bookPicker", {
  isOpen: false,
  selectedBooks: new Set<string>(),
  open() { /* … */ },
  toggleBook(id: string) { /* … */ },
});
<button @click="$store.bookPicker.open()">Add Books</button>
<span x-text="$store.bookPicker.selectedBooks.size"></span>

The server returns HTML (checked checkboxes included); the store tracks selection across swaps; nobody rebuilds markup from JSON.

Local state: plain x-data on the smallest element

Ephemeral UI (a dropdown, a panel toggle) needs no store and no round-trip:

<div x-data="{ dropdownOpen: false }">
  <button @click="dropdownOpen = !dropdownOpen">Toggle</button>
  <div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
</div>

The banned list — all enforced by the CSP build

  • No inline handlers, in any form: onclick=, onerror=, templ-brace variants — hash-mode CSP refuses them unconditionally. Rows generated in TypeScript carry data-* attributes and bind listeners after swap (see custom-section-builder.ts).
  • No hx-on* attributes and no js:-prefixed hx-vals — both evaluate strings, which the CSP build cannot do. Plain JSON hx-vals only (templates/utils.go has the helper).
  • No inline <script> outside theme_flash_guard.templ (the one sanctioned pre-paint script); everything else bundles from web/src.

Division of labor

The full ownership rule lives in Writing UI documentation without screenshots and the repo's AGENTS.md: templ decides what things look like; TypeScript decides what shows. Content derived from server data is rendered by templ (fetch a partial with htmx.ajax when it changes); TypeScript handles pure client-local state — open/closed panels, validation, toasts. classList/getElementById show-hide from TypeScript is a defect, not a shortcut.