Views & lifecycle
A view is a class extending View, decorated with @Component, constructed once inside its own injector scope by the framework's JSX interop — never by Preact directly.
Anatomy
import { Component, View, inject, signal } from '@preact-mvc/core';
import { UserService } from './user.service';
@Component()
export class ProfileView extends View<{ userId: string }> {
// Construction runs inside this view's injector scope — inject() in field
// initializers is the mount-time equivalent of constructor injection.
private users = inject(UserService);
private expanded = signal(false);
// props arrive through the constructor: readable in field initializers.
onInit() { console.log('mounted', this.props.userId); }
onDestroy() { console.log('unmounted'); }
render() {
const user = this.users.byId(this.props.userId);
return (
<article onClick={() => (this.expanded.value = !this.expanded.value)}>
<h3>{user.name}</h3>
{this.expanded.value && <p>{user.bio}</p>}
</article>
);
}
}The rules
- Views are classes, not hooks. A vnode-interception layer in
installViewInterop()(called bybootstrap) converts every JSX-referenced View subclass into a host component that constructs the class inside a fresh injector scope.render()stays hook-free. inject()belongs in field initializers or the constructor. That's when the injection context exists — the Angular "constructor injection" equivalent, without constructor boilerplate.render()reads signals and props. Signal reads inside render subscribe the view — writes re-render just that view. No state hooks, no deps arrays, no stale closures.- Props are construction-visible. The host assigns props before the constructor finishes, so field initializers can derive state from them (
View<Props>types them).
Lifecycle
| Hook | When |
|---|---|
onInit() | Scope activated — the view mounted and its injector resolved all managed services/controllers. Their onInits fire here too. |
onDestroy() | Scope deactivated — unmount. Fires on the view plus every service and controller created inside its scope. |
Activation is commit-time synchronous (a useLayoutEffect under the hood), so onInit/onDestroy always pair correctly — even for a view that mounts and unmounts within the same tick, like a fast redirect. DestroyRef.onDestroy callbacks run at the same moment for anything not implementing the interface.
The error boundary
Every view host is wrapped in a boundary. If construction or render throws — a missing provider, a scope-ownership violation, a render bug — the failed subtree renders a fallback box (with a Retry action that remounts the view) instead of crashing the whole app:
┌─ something went wrong ──────────────────┐
│ No provider for TodoStore (looked up │
│ from "root") [Retry] │
└──────────────────────────────────────────┘View-scoped providers and controllers
@Component({ providers, controllers }) registers dependencies in the view's own injector — children see them, siblings don't. Details in Component metadata and Controllers.