Chapter 03 — Controllers
Page logic with CoreController. Controllers are the brain of each route: the view is HTML; the controller wires refs, state, and events.
Mental model
Router swaps HTML into #main-content
→ lazy-loads your controller module
→ calls tasksController(params, state, loaderData)
→ you construct CoreController → onMount()
→ navigate away → destroy() → listeners gone
What a controller is
A controller runs after the router injects a view into #main-content. It wires refs, state, events, and returns a cleanup function.
Canonical pattern (what make:view / make:controller generate):
import { CoreController } from '@core/controller.js';
export class TasksController extends CoreController {
onMount() {
this.assertRefs('titleEl');
this.title = this.state('Tasks');
this.bind(this.title, this.titleEl);
}
onUnmount() {
// this.on() listeners auto-clean via destroy()
}
}
export function tasksController(_params, _state, _loaderData, rootElement) {
const ctrl = new TasksController(rootElement);
return () => ctrl.destroy();
}
The export name (tasksController) must match the first argument to lazyController('tasksController', '…').
CoreController API (verified)
| API | Purpose |
|---|---|
this.el | View root |
this.assertRefs(...names) | Throw if refs missing |
this.state(initial) | Local reactive { value } |
this.signal(initial) | [get, set] tuple |
this.compute(fn) / this.memo(fn) | Derived state |
this.effect(fn) | Reactive side effect |
this.bind(state, el) | textContent |
this.bind(state, el, 'attr') | attribute |
this.bind(state, el, '?disabled') | boolean attribute |
this.bind(state, el, '.class') | class toggle |
this.on(target, type, handler) | Listener with auto-cleanup |
this.$ / this.$$ | Query inside this.el |
this.rebind(root?) | Rescan refs after dynamic HTML |
destroy() | Cleanup + onUnmount |
Important: this.on requires an EventTarget first argument:
this.on(this.saveBtn, 'click', () => { /* … */ });
// not: this.on('click', handler)
this.bind(state, string) assigns an instance property named by that string (legacy overload). Prefer binding to a ref element: this.bind(state, this.titleEl).
Route params and loaders
The router calls the lazy controller as (params, state, loaderData).
Generated factories look like:
export function tasksController(_params, _state, _loaderData, rootElement) {
const ctrl = new TasksController(rootElement);
return () => ctrl.destroy();
}
createLazyController forwards only those three router args into the function export, so rootElement is usually undefined. CoreController then falls back to the active [data-view] root (which is correct for normal page views).
Example loader (optional) — signature is (params, signal), not an options object:
r.register('/tasks/:id', 'src/views/public/task-detail.html',
lazyController('taskDetailController', '../controllers/task-detail.controller.js'),
{
loader: async (params, signal) => {
const res = await fetch(`/api/tasks/${params.id}`, { signal });
return res.json();
},
});
Apply to Deskflow
Feature: Tasks page shows a reactive title when the primary button is clicked.
If make:view already created tasks.controller.js, open it and flesh out onMount using the scaffolded refs (titleEl, summaryEl, primaryBtn when present).
Or generate a standalone controller:
npm.cmd run make:controller -- tasks
Wire it in routes if needed (view generator usually does this).
Listening for events (preview)
Controllers hear the DOM through this.on(target, type, handler).
// Native events on built-ins / HTML
this.on(this.primaryBtn, 'click', () => {
this.title.value = 'Updated';
});
// Custom events from YOUR components (chapter 05)
// this.on(this.listEl, 'task-card-toggle', (e) => {
// console.log(e.detail);
// });
Rules:
- First argument is an EventTarget (a ref element,
this.el,window, …) this.onauto-removes the listener indestroy()— do not double-bind- For lists of custom elements, prefer one listener on the parent (delegation)
You will practice the custom-event half fully in Chapter 06.
Challenges
Bronze — On /tasks, clicking the primary button changes a bound title.
Silver — Disable the button when a text field (or state) is empty using this.bind(state, btn, '?disabled').
Gold — Navigate away and back: handlers must not stack (no double-firing). destroy() + this.on should already give you this.
Verify
- [ ] Navigating away and back does not duplicate click handlers
- [ ] Missing
reffails loudly viaassertRefswhen you call it - [ ] Factory returns
() => ctrl.destroy()
Common mistakes
| Mistake | Fix |
|---|---|
| Forgetting cleanup return | Always return destroy |
this.on('click', fn) | Use this.on(target, 'click', fn) |
trackEvents as the only taught pattern | Prefer CoreController for new pages |
| Binding with CSS selectors as primary API | Use ref elements |