Angular / Advanced / Lesson 13
Feature state and architecture
Choose one owner for each fact, expose derived views and explicit commands, and separate UI composition from transport and domain validation.
On this page
Lesson goals
- Distinguish local, feature, URL, and server-owned state.
- Build a small signal store with derived selection.
- Review boundaries, provider scope, and failure contracts.
Place a fact with the owner that can maintain it
Not every state value belongs in a root service. A temporary field can remain in a component, a shared workflow can use a feature-scoped service, navigable selection may belong in the URL, and authoritative records remain on the server. Duplicating the same fact in several writable places creates synchronization work and inconsistent outcomes. Identify the source of truth and derive other views where possible. A store is an organizational boundary, not automatic persistence, authorization, or immutability. Choose complexity from the feature’s real coordination needs rather than installing a state library before defining the data flow.
Store identity and derive the selected entity
The complete local signal model stores rows and a selected identifier, then derives the selected row. It does not keep a second writable copy of the selected title. Replacing a row representation can therefore update the derived view without manually synchronizing fields. The small model is not a full state-management framework and does not perform transport or persistence. Its inputs are synthetic already-valid records. A real feature should decode server data, validate transitions, and decide whether selection should survive a refresh that removes the selected entity. Model absence honestly instead of assuming every identifier always resolves.
import { computed, signal } from "@angular/core";
type Course = { readonly id: number; readonly title: string };
const courses = signal<readonly Course[]>([{ id: 1, title: "Basics" }, { id: 2, title: "Forms" }]);
const selectedId = signal<number | null>(2);
const selected = computed(() => courses().find(course => course.id === selectedId()));
console.log(selected()?.title ?? "none", courses().length);
courses.update(rows => rows.map(row => row.id === 2 ? { ...row, title: "Typed forms" } : row));
console.log(selected()?.title ?? "none");
Forms 2
Typed forms
The model prints Forms 2, then Typed forms. Selection follows the updated entity representation without another writable title field.
Separate orchestration from reusable presentation
A feature container can coordinate route state, a transport service, decoding, and a feature store. A presentation component receives data and emits intent without knowing endpoint URLs or global state. The contextual provider fragment places a feature store on a route boundary; it assumes the referenced store and page are implemented and the application uses the router. This scope is a deliberate lifetime choice and must be tested during navigation. Avoid a service that mixes HTTP, DOM access, storage, and every page’s flags simply because all features can inject it. Small boundaries make substitutions and failure tests clearer.
import { Routes } from "@angular/router";
import { CourseFeaturePage } from "./course-feature-page";
import { CourseFeatureStore } from "./course-feature-store";
export const featureRoutes: Routes = [
{ path: "courses", providers: [CourseFeatureStore], component: CourseFeaturePage }
];
The declared route injector owns this provider scope. Exact instance reuse and cleanup must be verified with the feature’s navigation policy; no router is started here.
Commands should preserve a documented invariant
Expose operations such as select, replaceLoadedRows, or submitChange instead of letting every consumer write each internal signal. A command can reject invalid input and coordinate loading or failure state. For remote changes, define whether the UI waits for confirmation or performs an optimistic update with reconciliation. An optimistic value is not proof of server persistence, and retrying a mutation needs an identity contract. Keep errors structured enough for the view to distinguish recoverable validation, denied access, missing data, and unexpected failure. Do not silently convert every error into an empty successful list.
Test boundaries through observable behavior
Test a state transition independently, then test the container with synthetic transport responses and the presentation through its inputs and outputs. Assert that two feature instances share state only when intended and that leaving an owner releases its resources. Public APIs should not expose private representation merely to simplify one test. A library or feature boundary also needs a dependency direction: domain utilities should not import a page component. Record the accepted data and failure contracts so future refactors can change implementation without breaking consumers. An understandable architecture reduces accidental coupling more reliably than a large folder tree with unclear responsibilities.
Common errors and fixes
Selected title becomes stale after a record refresh.
- Why it happens
- The same fact was copied into several writable fields.
- How to fix it
- Store identity and derive the selected view.
- Prevent it next time
- Test refresh of an already-selected entity.
Unrelated features overwrite each other’s temporary state.
- Why it happens
- A global service owns state that should be feature-scoped.
- How to fix it
- Provide state at the intended common feature owner.
- Prevent it next time
- Test independent feature instances.
An error is indistinguishable from an empty result.
- Why it happens
- Failure was normalized into successful data.
- How to fix it
- Represent result states explicitly.
- Prevent it next time
- Test every failure category at the container boundary.
Check your understanding
1. Set selectedId to nine in the local model. State the result and choose a deliberate policy when a refresh removes a selected entity.
Show worked solution
selected becomes undefined and the model’s fallback is none. The feature can clear selection, show a missing-record state, or retain the identifier for later reconciliation according to its product rule. It should not keep a stale title from a previously selected row. Add a test for the chosen transition and ensure the URL contract agrees if selection is navigable.
2. A reusable card injects the feature HTTP service and also emits selected. Explain the coupling and a clearer alternative.
Show worked solution
The card now depends on transport policy and feature scope in addition to presentation, making reuse and isolated interaction tests harder. Let the container load and validate data, pass the card its required inputs, and handle its intent output. If the card truly owns a remote operation, document that responsibility explicitly instead of hiding it behind a supposedly presentational API.
Key takeaways
- Give each fact one authoritative owner.
- Derive views and expose explicit commands.
- Keep transport, domain, and presentation boundaries reviewable.