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

profile.view.tsx
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 by bootstrap) 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

HookWhen
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.