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
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, whosecreateComponentroutes every JSX-referenced View subclass throughviewComponent()— 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
| 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 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.