Views & lifecycle

A view is a class extending View, decorated with @Component, constructed once inside its own injector scope by the framework's createComponent seam — never by Solid directly.

Anatomy

profile.view.tsx
import { Component, View, inject, signal } from '@solid-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. JSX compiles against @solid-mvc/core/web, whose createComponent routes every JSX-referenced View subclass through viewComponent() — a host component that constructs the class inside a fresh injector scope. Solid never instantiates them itself; 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() runs once; JSX tracks. Signal reads inside JSX expressions, attributes, <For>/<Show> update exactly the DOM that read them — no re-render, no deps arrays, no stale closures. Eager reads in the render body are snapshots.
  • Props are construction-visible and reactive. The host assigns a getter-backed props object before the constructor finishes — field initializers read initial values, JSX reads stay live (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 synchronous with the host mount, 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.