@jdlanglois/emveecee
v0.3.0
Published
A tiny closure-first controller and router library for single-page applications.
Readme
emveecee
A tiny, closure-first TypeScript library for single-page applications. It owns routing and controller lifecycles, while your application owns its state, models, services, and rendering technology.
Install
npm install @jdlanglois/emveeceeUsage
import { createApp } from "@jdlanglois/emveecee";
import type { Controller } from "@jdlanglois/emveecee";
type AppDeps = {
someService: SomeService;
};
const dashboardCtrl: Controller<AppDeps> = app => {
async function load() {
const message = await app.deps.someService.getMessage();
app.target.innerHTML = `<h1>${message}</h1>`;
}
function unload() {
app.target.replaceChildren();
}
return { load, unload };
};
const app = createApp<AppDeps>(({ router }) => {
router.route("/", dashboardCtrl);
return {
someService: new SomeService(),
};
});
app.start(document.body);Each route activation creates a new controller closure. Put page-local state in
that closure and application-wide state or services in app.deps.
Controllers are ordinary functions. JavaScript needs no helper:
const dashboardCtrl = app => ({
load() {
app.target.innerHTML = "<h1>Dashboard</h1>";
},
});TypeScript users who prefer contextual typing can optionally use ctrl():
import { ctrl } from "@jdlanglois/emveecee";
const dashboardCtrl = ctrl<AppDeps>(app => ({
load() {
app.deps.someService.load();
},
}));Routes
const productCtrl: Controller<AppDeps> = app => ({
async load(route) {
const product = await app.deps.someService.getProduct(
route.params.id,
{ signal: route.signal },
);
if (!route.signal.aborted) {
app.target.textContent = product.name;
}
},
}));
router.route("/products/:id", productCtrl);
router.route("*", notFoundCtrl);The route passed to load() contains:
path: the current pathnameparams: decoded named path parametersquery: the nativeURLSearchParamssignal: aborted when the controller is left
Navigate programmatically with app.navigate("/products/42"). Links marked
with data-route are handled through the History API:
<a href="/products/42" data-route>View product</a>Lifecycle
A controller must return load and may return unload:
const timerCtrl: Controller<AppDeps> = app => {
let timer: ReturnType<typeof setInterval>;
return {
load() {
timer = setInterval(render, 1000);
},
unload() {
clearInterval(timer);
},
};
};app.stop() removes navigation listeners, aborts the active route, and awaits
its unload() function.
Views and redrawing
Views receive values and callbacks from their controller. The controller owns the model, handles application behavior, and decides when a redraw is needed. The view renders the values it receives and connects UI events to the supplied callbacks.
The resulting one-way loop is:
model -> controller render -> view -> callback -> model update -> renderA useful view contract is a target-bound renderer:
type View<Props> = {
render(props: Props): void;
dispose?(): void;
};
type CounterProps = {
count: number;
onIncrement(): void;
onReset(): void;
};The controller keeps its model private and passes the view only the values and actions it needs:
const counterCtrl: Controller<AppDeps> = app => {
const model = {
count: 0,
};
const view = createCounterView(app.target);
function render() {
view.render({
count: model.count,
onIncrement,
onReset,
});
}
function onIncrement() {
model.count++;
render();
}
function onReset() {
model.count = 0;
render();
}
return {
load: render,
unload: () => view.dispose?.(),
};
};The library does not automatically redraw when a model changes. This keeps models as ordinary objects and allows the controller to represent intermediate states explicitly:
async function onSave() {
model.saving = true;
render();
try {
await app.deps.products.save(model.product);
} finally {
model.saving = false;
render();
}
}Direct DOM view
Replacing a view's subtree on each render also discards the old elements and their event listeners:
function createCounterView(target: HTMLElement): View<CounterProps> {
return {
render(props) {
const root = document.createElement("section");
const count = document.createElement("p");
const increment = document.createElement("button");
const reset = document.createElement("button");
count.textContent = `Count: ${props.count}`;
increment.textContent = "Increment";
reset.textContent = "Reset";
increment.addEventListener("click", props.onIncrement);
reset.addEventListener("click", props.onReset);
root.append(count, increment, reset);
target.replaceChildren(root);
},
dispose() {
target.replaceChildren();
},
};
}Preact view
Preact keeps its render root associated with the target, so repeated calls to
render() update the existing component tree:
import { h, render as renderPreact } from "preact";
function createCounterView(target: HTMLElement): View<CounterProps> {
return {
render(props) {
renderPreact(
h("section", null,
h("p", null, `Count: ${props.count}`),
h("button", { onClick: props.onIncrement }, "Increment"),
h("button", { onClick: props.onReset }, "Reset"),
),
target,
);
},
dispose() {
renderPreact(null, target);
},
};
}The same controller can use a JSX-based Preact view; the controller only cares
that the view implements render() and optionally dispose().
lit-html view
lit-html also preserves its rendering state between calls for the same target:
import { html, nothing, render as renderHtml } from "lit-html";
function createCounterView(target: HTMLElement): View<CounterProps> {
return {
render(props) {
renderHtml(html`
<section>
<p>Count: ${props.count}</p>
<button @click=${props.onIncrement}>Increment</button>
<button @click=${props.onReset}>Reset</button>
</section>
`, target);
},
dispose() {
renderHtml(nothing, target);
},
};
}Responsibility boundary
Controllers should:
- Own or load models and application state.
- Call services and handle route parameters.
- Define actions passed to the view.
- Handle loading, success, and error states.
- Decide when to render and dispose the active view.
Views should:
- Turn values into UI.
- Connect UI events to controller callbacks.
- Handle presentation-specific behavior such as focus or animation.
- Release resources owned by the rendering technology when disposed.
Views should call props.onIncrement() rather than mutating a controller model,
calling application services, or navigating directly. Intent-oriented callback
names such as onIncrement and onSave also keep views independent of whether
an action came from a click, keyboard shortcut, or another UI event.
Hiccup-style view
The optional @jdlanglois/emveecee/view entry renders array-based views and
diffs later renders against the current DOM:
import { render } from "@jdlanglois/emveecee/view";
render([
"section#counter.panel",
{ "aria-live": "polite" },
["p", `Count: ${count}`],
items.map(item => ["span.item", { key: item.id }, item.label]),
null,
["button", { onClick: increment }, "Increment"],
], target);The optional attributes object follows the selector. Nested child arrays are
flattened, and null, undefined, and false children are omitted. Reusing the
same target preserves compatible elements while updating attributes, event
listeners, text, and children.
Attributes are optional. For efficient list updates, put a stable scalar key
on each child. Alternatively, put a key function on the parent attributes:
render(["ul", { key: attrs => Number(attrs.id) },
...items.map(item => ["li", { id: item.id }, item.label]),
], target);Keyed reconciliation moves existing DOM nodes when items are reordered and only creates or removes nodes for inserted or deleted keys. Keys must be unique among siblings and are not emitted as HTML attributes.
Size
The ESM view entry is 3,692 B (3.61 KiB) minified and 1,628 B (1.59 KiB)
minified+gzip. These figures measure the complete dist/view.js produced by
the current build. Rebuild and refresh the measurement with:
npm run size:viewPhilosophy
- View-library agnostic: use the optional Hiccup renderer, React, templates, or direct DOM manipulation.
- No model abstraction: ordinary JavaScript objects are enough.
- No automatic rendering: controllers decide when views are rendered.
- No classes or inheritance: applications and controllers are closures.
- No runtime dependencies.
License
MIT
