Build client UI as structured web components
What you'll see
A client-side Docly app starts as a single index.html with one big app.js that queries elements by id, builds markup with string concatenation, and wires up event handlers globally. It works at first. As the app grows — more screens, more lists, more forms calling more #/API/ endpoints — the script becomes a tangle of document.getElementById, duplicated render logic, and shared mutable state. A change to one widget breaks another, the same card markup is copy-pasted across pages, and nobody can tell which function owns which part of the DOM.
What's actually happening
Docly serves plain HTML, CSS and JavaScript for client-side apps and handles the backend through #/API/ endpoints. Nothing about that requires — or excludes — a particular frontend architecture. The browser's own Web Components standard (custom elements, shadow DOM, templates) is available everywhere, needs no framework, no bundler and no build step, which makes it a natural fit for the zero-config Docly model.
A custom element bundles three things that otherwise drift apart: the markup it renders, the behavior that reacts to user input, and the state it owns. Instead of a global script reaching into shared DOM, each element is responsible for its own subtree. Shadow DOM scopes its styles so one component's CSS can't leak into another's. The element exposes a small surface — attributes and properties in, events out — so pages compose features by placing tags, not by orchestrating imperative DOM code.
The payoff grows with the app. A <customer-card> defined once renders identically on the dashboard, the search results and the detail page. A <data-table> that fetches from a #/API/ endpoint can be dropped onto any page with a single attribute. Bugs stay local to the component that owns them, and a component can be tested in isolation because it has no hidden dependency on the rest of the page.
What to do
Once a client-side app has more than a couple of repeated UI pieces or more than one page, define those pieces as custom elements rather than ad-hoc DOM scripts.
- One component per file — keep each
customElements.define(...)in its own JS file under your UI folder, named after the tag. - Encapsulate state — hold the component's data in instance fields, not global variables. Re-render from
render()when it changes. - Attributes in, events out — pass data down via attributes/properties; signal up via
this.dispatchEvent(new CustomEvent(...)). Don't reach across components. - Fetch with
~/— call endpoints asfetch('~/API/...')so the component is portable across working copies (see related entry). - Scope styles — use shadow DOM (or a clear class prefix) so component CSS doesn't leak.
Don't — one global script owning the whole DOM:
// app.js
async function loadCustomers() {
const res = await fetch('/API/customers'); // also wrong: leading slash
const data = await res.json();
const list = document.getElementById('list');
list.innerHTML = data.map(c =>
`<div class="card">${c.name}</div>`).join('');
}
loadCustomers();Do — a self-contained component:
// /ui/customer-list.js
class CustomerList extends HTMLElement {
connectedCallback() { this.load(); }
async load() {
const res = await fetch('~/API/customers');
this.customers = await res.json();
this.render();
}
render() {
this.innerHTML = '';
for (const c of this.customers) {
const card = document.createElement('customer-card');
card.customer = c;
this.appendChild(card);
}
}
}
customElements.define('customer-list', CustomerList);Compose pages by placing tags:
<!-- /ui/index.html -->
<script src="/ui/customer-card.js"></script>
<script src="/ui/customer-list.js"></script>
<customer-list></customer-list>The same <customer-card> now renders on every page that needs it, the list owns its own fetch-and-render cycle, and a change to either stays contained. The app scales by adding tags, not by growing one script.