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

users.controller.ts
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); }
}
users.view.tsx
@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 declaredWho 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.