Dependency Injection
Lifetimes and Scopes
singleton, scoped and transient; the navigation scope and scoped handles.
Lifetimes
| Lifetime | Behaviour |
|---|---|
singleton |
A single instance for the whole application. |
scoped |
One instance per navigation. Everything on screen in the same navigation (the layout, the page, child components, FromService, singleton dependencies) sees the same instance; details below. Without a router, it is a single instance in the root provider. |
transient |
A new instance on every resolution (the default). |
The lifetime is given on the registration (addSingleton / addScoped / addTransient, or @Injectable({ lifetime }); see Registering Services). If a route component itself is registered in DI, the registration's lifetime decides the page's lifetime (see How a route component is built).
scoped: a single instance per navigation
The router opens a scope on every navigation. Everyone who asks for a scoped service (getService, inject, deps, FromService) gets not the service itself but a handle that leads to it. The handle is used like the service (fields, methods, #private fields, instanceof) and goes to the instance of the right navigation on every access. In the example, the layout and the page use the same scoped selection:
import { Injectable, reactive } from "@motifx/core";
@Injectable({ lifetime: "scoped" })
export class Selection {
ids = reactive<number[]>([]);
toggle(id: number) {
const i = this.ids.indexOf(id);
if (i < 0) this.ids.push(id);
else this.ids.splice(i, 1);
}
}
import { Injectable, reactive } from "@motifx/core";
@Injectable({ lifetime: "scoped" })
export class Selection {
ids = reactive([]);
toggle(id) {
const i = this.ids.indexOf(id);
if (i < 0) this.ids.push(id);
else this.ids.splice(i, 1);
}
}
The counter in the layout and the button on the note page ask for the same service:
import { Component } from "@motifx/core";
import { Selection } from "../services/selection";
import type { Note } from "../services/notes";
export class SelectedCount extends Component {
selection = this.getService(Selection)!;
override view() {
return <span class="badge">{() => `${this.selection.ids.length} selected`}</span>;
}
}
export class SelectButton extends Component<any, { note: Note }> {
selection = this.getService(Selection)!;
override view() {
const { note } = this.props;
return (
<button type="button" class="button--sm ghost" onclick={() => this.selection.toggle(note.id)}>
Select
</button>
);
}
}
import { Component, ComponentBase, motifComponent } from "@motifx/core";
import { Selection } from "../services/selection";
import type { Note } from "../services/notes";
export class SelectedCount extends Component {
selection = this.getService(Selection)!;
override view() {
return motifComponent("span", (span: ComponentBase) => {
span.class.add("badge");
span.bindings.text(() => `${this.selection.ids.length} selected`);
});
}
}
export class SelectButton extends Component<any, { note: Note }> {
selection = this.getService(Selection)!;
override view() {
const { note } = this.props;
return motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => this.selection.toggle(note.id));
button.setText("Select");
});
}
}
import { FromService } from "@motifx/core";
import { Selection } from "../services/selection";
import type { Note } from "../services/notes";
export function SelectedCount() {
const selection = FromService(Selection)!;
return <span class="badge">{() => `${selection.ids.length} selected`}</span>;
}
export function SelectButton(props: { note: Note }) {
const selection = FromService(Selection)!;
const { note } = props;
return (
<button type="button" class="button--sm ghost" onclick={() => selection.toggle(note.id)}>
Select
</button>
);
}
import { ComponentBase, FromService, motifComponent } from "@motifx/core";
import { Selection } from "../services/selection";
import type { Note } from "../services/notes";
export function SelectedCount() {
const selection = FromService(Selection)!;
return motifComponent("span", (span: ComponentBase) => {
span.class.add("badge");
span.bindings.text(() => `${selection.ids.length} selected`);
});
}
export function SelectButton(props: { note: Note }) {
const selection = FromService(Selection)!;
const { note } = props;
return motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => selection.toggle(note.id));
button.setText("Select");
});
}
import { motifOptions } from "@motifx/core";
import { Selection } from "../services/selection";
import type { Note } from "../services/notes";
export const SelectedCount = () => motifOptions({
el: null,
view() {
const selection = this.getService(Selection)!;
return <span class="badge">{() => `${selection.ids.length} selected`}</span>;
},
});
export const SelectButton = (props: { note: Note }) => motifOptions({
el: null,
view() {
const selection = this.getService(Selection)!;
const { note } = props;
return (
<button type="button" class="button--sm ghost" onclick={() => selection.toggle(note.id)}>
Select
</button>
);
},
});
import { ComponentBase, motifComponent, motifOptions } from "@motifx/core";
import { Selection } from "../services/selection";
import type { Note } from "../services/notes";
export const SelectedCount = () => motifOptions({
el: null,
view() {
const selection = this.getService(Selection)!;
return motifComponent("span", (span: ComponentBase) => {
span.class.add("badge");
span.bindings.text(() => `${selection.ids.length} selected`);
});
},
});
export const SelectButton = (props: { note: Note }) => motifOptions({
el: null,
view() {
const selection = this.getService(Selection)!;
const { note } = props;
return motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => selection.toggle(note.id));
button.setText("Select");
});
},
});
import { Component } from "@motifx/core";
import { Selection } from "../services/selection";
export class SelectedCount extends Component {
selection = this.getService(Selection);
view() {
return <span class="badge">{() => `${this.selection.ids.length} selected`}</span>;
}
}
export class SelectButton extends Component {
selection = this.getService(Selection);
view() {
const { note } = this.props;
return (
<button type="button" class="button--sm ghost" onclick={() => this.selection.toggle(note.id)}>
Select
</button>
);
}
}
import { Component, motifComponent } from "@motifx/core";
import { Selection } from "../services/selection";
export class SelectedCount extends Component {
selection = this.getService(Selection);
view() {
return motifComponent("span", (span) => {
span.class.add("badge");
span.bindings.text(() => `${this.selection.ids.length} selected`);
});
}
}
export class SelectButton extends Component {
selection = this.getService(Selection);
view() {
const { note } = this.props;
return motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => this.selection.toggle(note.id));
button.setText("Select");
});
}
}
import { FromService } from "@motifx/core";
import { Selection } from "../services/selection";
export function SelectedCount() {
const selection = FromService(Selection);
return <span class="badge">{() => `${selection.ids.length} selected`}</span>;
}
export function SelectButton(props) {
const selection = FromService(Selection);
const { note } = props;
return (
<button type="button" class="button--sm ghost" onclick={() => selection.toggle(note.id)}>
Select
</button>
);
}
import { FromService, motifComponent } from "@motifx/core";
import { Selection } from "../services/selection";
export function SelectedCount() {
const selection = FromService(Selection);
return motifComponent("span", (span) => {
span.class.add("badge");
span.bindings.text(() => `${selection.ids.length} selected`);
});
}
export function SelectButton(props) {
const selection = FromService(Selection);
const { note } = props;
return motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => selection.toggle(note.id));
button.setText("Select");
});
}
import { motifOptions } from "@motifx/core";
import { Selection } from "../services/selection";
export const SelectedCount = () => motifOptions({
el: null,
view() {
const selection = this.getService(Selection);
return <span class="badge">{() => `${selection.ids.length} selected`}</span>;
},
});
export const SelectButton = (props) => motifOptions({
el: null,
view() {
const selection = this.getService(Selection);
const { note } = props;
return (
<button type="button" class="button--sm ghost" onclick={() => selection.toggle(note.id)}>
Select
</button>
);
},
});
import { motifComponent, motifOptions } from "@motifx/core";
import { Selection } from "../services/selection";
export const SelectedCount = () => motifOptions({
el: null,
view() {
const selection = this.getService(Selection);
return motifComponent("span", (span) => {
span.class.add("badge");
span.bindings.text(() => `${selection.ids.length} selected`);
});
},
});
export const SelectButton = (props) => motifOptions({
el: null,
view() {
const selection = this.getService(Selection);
const { note } = props;
return motifComponent("button", (button) => {
button.attr.add({ type: "button" });
button.class.add("button--sm ghost");
button.motif.on("click", () => selection.toggle(note.id));
button.setText("Select");
});
},
});
The layout places the counter with <SelectedCount />, and the note page places the button with <SelectButton note={note} />.
The layout places the counter with new SelectedCount(), and the note page places the button with new SelectButton({ note }).
The layout places the counter with motifComponent(SelectedCount), and the note page places the button with motifComponent(SelectButton, { note }).
- The layout sees a change made by the page at the same moment; both are on the same navigation's instance.
- When moving to another page, the layout's handle moves to the new navigation's instance on its own, and the counter's binding refreshes. The previous navigation's instance is disposed (
dispose()) after the leaving page has been disposed. - The leaving page stays tied to the previous navigation's instance; the instance is alive while the page is being disposed (
onDisposing,onDisposed) too. Work that completes after the page has left (after anawait) writes to its own navigation's instance, not to the new page's service. A parameter change on the same route (/notes/1→/notes/2) is a new navigation as well. - A
keepAlivepage keeps its own navigation's instance alive while it waits in the cache; when it is shown again, it moves to the current navigation's instance beforeonActivatedruns, so a value written to the service insideonActivatedstays in the current instance. Pages waiting in the mobile stack follow the same rule. If the state in the service should be kept across navigations, the service should be asingleton. - Back and forward navigation is a new navigation too, and opens a new instance.
- If the navigation does not happen (a guard cancels it, the page fails to build), the current instance does not change; the scope that was opened is disposed.
- While a page is being built (in the constructor, or in work the constructor starts), the new navigation's instance is used.
- A scope opened in code with
app.provider.createScope()is outside this arrangement: it gives the service itself, which lives only in that scope. For transient instances produced in such a scope to be disposed together with the scope, see Disposing Services. - A handle is a separate object per component: the handles two components get are not equal with
===even though they go to the same instance; compare services by their content.
Since the fields of a disposed component are cleared, checking the component's isDisposed field in long-running work is still a good habit.
Next step
To learn where and how services are obtained, go on to Resolving Services.