Controllers
Controllers own interaction state — form fields, selections, draft values, actions — so views stay declarative. They're plain injectable classes; where you declare one decides who shares the instance.
MVC in practice
import { Controller, inject, signal } from '@preact-mvc/core';
import { UsersApi } from './users.api';
@Controller()
export class UsersController {
private api = inject(UsersApi);
search = signal('');
selected = signal<Set<number>>(new Set());
// Debounce lives in the controller — the view just binds.
private timer?: ReturnType<typeof setTimeout>;
setSearch(v: string) {
clearTimeout(this.timer);
this.timer = setTimeout(() => (this.search.value = v), 250);
}
toggle(id: number) {
const next = new Set(this.selected.value);
next.has(id) ? next.delete(id) : next.add(id);
this.selected.value = next;
}
onDestroy() { clearTimeout(this.timer); }
}@Component({ controllers: [UsersController] })
export class UsersView extends View {
private c = inject(UsersController);
render() {
return (
<input
placeholder="Search…"
onInput={(e) => this.c.setSearch(e.currentTarget.value)}
/>
// …
);
}
}The scope matrix
Declaring a controller registers it in that injector — resolution checks explicit registrations up the whole ancestor chain first, and only falls back to a fresh transient instance when nothing is declared:
| Where declared | Who shares the instance |
|---|---|
Nowhere — just inject(C) | Transient: one per injecting scope |
@Component({ controllers }) | That view's whole subtree — parent and nested child views get the same instance |
@Module({ controllers }) | Every view in the module's scope — a module-wide session controller |
This makes where you declare semantically meaningful: a form controller declared on the form view is shared with the field components inside it; declared on the module it becomes feature-wide state.
useController — the escape hatch
Inside a function component (or when you explicitly want a fresh per-mount controller regardless of declarations), useController(C) creates an instance scoped to that component mount and runs its lifecycle with it:
function TagEditor() {
// Fresh controller per mounted TagEditor — onInit/onDestroy run with it.
const c = useController(TagEditorController);
return <input value={c.draft.value} onInput={...} />;
}More on the hooks in Context & hooks.