// 01Insight
Signals before ceremony
Most UI state does not need a store. Start in the template, promote only what the product actually shares.
· Rabin R
Every Angular codebase I have inherited has had the same layer in it: a store that exists because the team was told a store was best practice, not because any two parts of the application actually needed to agree on the same value. On the Fiji immigration internal system there were services holding BehaviorSubjects for state that never left the component that created it — a filter panel open/closed flag, the currently expanded row in a table. Each one cost a subscription, an unsubscribe, and a file to open before anyone could understand the template.
What the ceremony actually costs
Here is the shape it usually takes. A boolean that one template reads, wrapped in enough machinery to look like architecture:
// filter-panel.service.ts
@Injectable({ providedIn: 'root' })
export class FilterPanelService {
private readonly openSubject = new BehaviorSubject<boolean>(false);
readonly open$ = this.openSubject.asObservable();
toggle(): void {
this.openSubject.next(!this.openSubject.value);
}
}
// filter-panel.component.ts
export class FilterPanelComponent implements OnInit, OnDestroy {
open = false;
private readonly destroy$ = new Subject<void>();
constructor(private readonly panel: FilterPanelService) {}
ngOnInit(): void {
this.panel.open$
.pipe(takeUntil(this.destroy$))
.subscribe((open) => (this.open = open));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Nothing here is wrong, exactly. It is that the entire file pair exists to do what one line does:
export class FilterPanelComponent {
readonly open = signal(false);
toggle(): void {
this.open.update((v) => !v);
}
}No subscription, no teardown, no second file. A developer reading the template sees the declaration without navigating anywhere. That proximity is the whole benefit, and it is worth more than it looks — most of the time I have spent being slow in an unfamiliar Angular codebase went on following a value backwards through layers to find out where it came from.
The promotion rule
The rule I now apply is narrow, and it has held up across immigration case management, a pension member portal and an insurance administration console: state starts in the template, and it is promoted only when a second consumer appears. Not when a second consumer is imagined — when one actually exists in the code.
Promotion has three steps and I take them in order. First the signal moves from the component to a service, still a signal. Second, if derived values start being recomputed in more than one place, those become computed signals in the same service, so the derivation has exactly one definition. Third — and this is rare — if the state has to survive navigation or be written from unrelated parts of the tree, it earns a store.
@Injectable({ providedIn: 'root' })
export class CaseQueueStore {
private readonly cases = signal<Case[]>([]);
readonly filter = signal<CaseFilter>('all');
readonly visible = computed(() => {
const filter = this.filter();
return filter === 'all'
? this.cases()
: this.cases().filter((c) => c.status === filter);
});
readonly outstanding = computed(
() => this.visible().filter((c) => !c.assignedTo).length,
);
load(cases: Case[]): void {
this.cases.set(cases);
}
}Most state never reaches step three. On the PRIMS member portal, exactly two pieces of state did: the authenticated member context, and the active claim being edited across a multi-step flow. Everything else — table filters, expanded rows, form step position, panel visibility — stayed local or stopped at a service.
Why the ordering matters
Ceremony is not free, and its cost is paid at the wrong time. A store adds actions, reducers or updaters, selectors, and a mental model that a new developer has to load before they can change a label. That cost is invisible while the team that wrote it is still on the project, and it is the dominant cost afterwards. I have spent more hours tracing a value back through three layers of abstraction to find it was only ever read once than I have spent fixing genuine shared-state bugs.
Signals changed the economics here in a way I think is still underrated. Before signals, keeping state in a component and sharing it later meant a rewrite: template-local fields became observables, templates gained async pipes, and change detection behaviour shifted. The migration was expensive enough that teams pre-emptively started in the store to avoid it. With signals, a component-local signal and a service-level signal are the same primitive — moving one to the other is a cut and a paste. The cost of starting small dropped to nearly zero, which makes starting small the rational default rather than an optimistic one.
Zoneless makes the same point from the other side
Once change detection is driven by signal reads rather than by a zone patching every async API, the framework rewards state that is precisely scoped, because only the components that actually read a signal re-render. Broad, shared, store-held state means broad invalidation.
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});On the Zellavora resume builder, which is zoneless and signal-driven throughout, the components that re-render on a keystroke are the ones displaying the edited field — not because of an optimisation pass, but because that is what the state graph says should happen. When state is scoped precisely, performance is a consequence of the architecture rather than a separate workstream.
The test I actually use
Before adding a store: can I name the second consumer, and can I point at its file? If the answer is a shape the application might take next quarter, the state stays in the template.
It is far easier to promote state that turned out to be shared than to demote state that turned out not to be. The first direction is a cut and paste; the second is a conversation with a product owner about why you want to spend a sprint changing nothing a user can see. That asymmetry is the entire argument. Refactors nobody schedules never happen, so the ceremony stays in the codebase for as long as the codebase lives.
If you have an Angular codebase where changing one screen means opening six files, that is usually a state-layer problem rather than a discipline problem. I do scoped assessments that say which parts are worth fixing, and in what order.
