A page application has no framework. No components, no virtual DOM, no reconciler. That is less of a problem than it sounds, provided you pick one way to hold state and stick to it.
There are two, and they are for different jobs.
The runtime store: appmint.state
An in-memory reactive store with path access and subscribers.
appmint.state.set('filters.category', 'chairs');
appmint.state.get('filters.category'); // 'chairs'
appmint.state.inc('step');
appmint.state.dec('step');
appmint.state.toggle('panel.open');What makes it more than a scratchpad: data-bind resolves against it, and subscribers re-render on change. Setting a value updates every element bound to that path, with no redraw code of your own.
<span data-bind="filters.category"></span>
<button data-action="state.set"
data-payload='{"path":"filters.category","value":"tables"}'>Tables</button>Click the button, the span changes. You wrote no JavaScript.
What it costs
It is session-only. The store lives in memory. A reload clears it, and there is no persistence hook you can reach — the namespaces themselves decide what to persist, and only the cart does (to localStorage under cart-storage, for compatibility with the existing cart store). Nothing you put in state survives a refresh.
It is global. One store per page, addressed by path. Two features using step collide. Namespace your paths (checkout.step, designer.step) from the start, because renaming them later means finding every data-bind and data-payload that mentions them.
Every set notifies. Fine for a handful of bound elements; not what you want inside a drag handler firing at 60fps.
Use it for
UI state that markup binds to — the open panel, the active tab, the selected filter, the current step. Anything where the win is that attributes re-render themselves.
Your own object
For anything with structure, or anything that changes faster than the DOM should:
var st = {
view: 0,
sel: {}, // selected option index per attribute
txt: {}, // free-text values
file: {}, // uploaded files
texts: [], // canvas text layers
selT: -1, // which layer is selected
};That is the real state shape from Print Oxygen's design studio, unedited. A plain object, no library, and every render function reads from it.
Why not put that in state?
Three reasons, all of which show up in a real build:
Shape. texts is an array of objects, each with a dozen fields, reordered and spliced as layers are added and deleted. Path-addressed access is the wrong tool for that.
Frequency. Dragging a text layer updates x and y continuously. You want to redraw one canvas, not notify every subscriber on every mouse move.
Locality. Nothing outside the designer needs to read it. Global state for something one module owns is a cost with no benefit.
The two coexist happily. Use appmint.state for the handful of values your markup binds to, and a plain object for your application's working data. Do not try to force everything into one of them.
Rendering without a framework
The pattern that holds up: named render functions, each owning one region, all reading from the same state, called explicitly after a change.
function renderControls() { /* redraw the options panel from st */ }
function renderSides() { /* redraw the view switcher */ }
function paint() { /* redraw the canvas */ }
function price() { /* recompute and show the total */ }
// after any change that matters
function changed() { paint(); price(); }Initialisation is just calling them once:
renderControls(); renderSides(); price(); buildTextbar();
if (MODE === 'upload') { renderUploadMode(); } else { paint(); }This is unfashionable and it works. The discipline that keeps it working is the part worth stating: a render function reads state and writes DOM. It never writes state. The moment a render function mutates st, you have a loop you cannot reason about, and the symptom is a UI that is correct only on the second interaction.
Full redraw, not patching
Redraw whole regions rather than tracking which node changed. For a panel of a few dozen elements the cost is nothing, and the correctness win is total — there is no stale-node class of bug, because there are no retained nodes.
Reach for targeted updates only where you have measured a problem, which in practice means canvas drawing and drag handlers.
Rebinding after a redraw
If you replace a region's innerHTML, its listeners went with it. Two options:
Rebind in the render function — simple, correct, fine for panels that redraw on discrete interactions:
function renderControls() {
wrap.innerHTML = '';
options.forEach(function (o) {
var b = document.createElement('button');
b.textContent = o.label;
b.addEventListener('click', function () { st.sel[name] = o.index; changed(); });
wrap.appendChild(b);
});
}Or delegate from a stable parent — one listener, survives any redraw:
wrap.addEventListener('click', function (e) {
var b = e.target.closest('[data-opt]');
if (!b) return;
st.sel[b.dataset.attr] = +b.dataset.opt;
changed();
});Delegation is the better default once a region redraws often.
Building elements with createElement and textContent, as above, is not a style preference. Any state value interpolated into an innerHTML string is an injection site, and product names, customer text and option labels all come from outside your code. Use textContent for anything a person typed or a record supplied.
Deriving, not storing
Store the minimum and compute the rest. A configurator that stores both the selections and the price has two things to keep in step, and they will drift.
// wrong — two sources of truth
st.sel.size = 2;
st.price = 45;
// right — one source, price derived
st.sel.size = 2;
function price() {
var total = P.base;
P.attributes.forEach(function (a) {
var o = a.options && a.options[st.sel[a.name] || 0];
if (o && o.price) total += o.price;
});
return total;
}Derived display prices are for display. The moment money is committed, the server prices it — cart.recalc() and storefront/pricing/calculate-cart return the figures to render. A page that computes a total and treats it as authoritative will eventually disagree with the order, and the customer will be right.
The three states every bound region needs
Most page applications ship only the success state, then look broken on a slow connection and inexplicable on a failure.
function load() {
note.textContent = 'Loading…';
list.innerHTML = '';
appmint.repository.find('sf_product', query)
.then(function (r) {
var rows = (r && r.data) || [];
if (!rows.length) {
note.textContent = 'Nothing matches those filters.';
return;
}
note.textContent = rows.length + ' results';
rows.forEach(renderRow);
})
.catch(function () {
note.textContent = 'Could not load that just now. Try again, or email us.';
});
}Loading, empty and error, every time. The empty state is the one most often skipped and the one users hit most, and "Nothing matches those filters" is a different message from "Could not load" — conflating them tells someone their search failed when their search simply found nothing.
Checklist
-
appmint.statefor values markup binds to; a plain object for working data - State paths namespaced by feature
- Render functions read state, never write it
- Whole regions redrawn; delegation for anything redrawn often
-
textContentfor anything a person or a record supplied - Prices derived, never stored — and never authoritative
- Loading, empty and error handled distinctly
Next: the design studio case study, where all of this is running in production.