Imported from FerroxLabs/wayland (
src/process/resources/skills-library/bodies/skills/web-development/angular-signals-patterns/SKILL.md). Install upstream withnpx skills add FerroxLabs/wayland --skill angular-signals-patterns. Copyright stays with the author (Apache-2.0).
Angular Signals Patterns
When to Use
Use this skill when:
- User asks how to implement Angular Signals (introduced in Angular 16, stabilized in Angular 17+) for reactive state management in components, services, or application-wide stores
- User wants to replace RxJS-heavy component state with signal-based reactivity and needs guidance on which patterns to use and when
- User asks about
signal(),computed(),effect(),toSignal(),toObservable(), or theinput()/output()/model()signal-based component APIs introduced in Angular 17--18 - User needs to design a signal-based state management architecture -- local component state, feature-level stores, or global application state using the SignalStore pattern from NgRx or custom implementations
- User is migrating a component or service from
BehaviorSubject-based patterns to signals and needs a concrete migration path with interop strategies - User wants to understand signal equality functions, custom comparators, or how to prevent unnecessary recomputations in deep object graphs
- User asks about
linkedSignal(), resource signals, or the experimental async signal primitives introduced in Angular 18+ - User is debugging unexpected signal re-evaluations, glitch-prone computed chains, or
effect()infinite loops
Do NOT use this skill when:
- User is working with Angular versions below 16 -- signals do not exist; redirect to RxJS observable patterns for Angular state management
- User needs general RxJS patterns not related to signal interop -- check the rxjs-patterns skill in this subcategory
- User is building with React, Vue, Svelte, or Solid.js, even though those frameworks have analogous reactive primitives -- the APIs and scheduling semantics differ fundamentally
- User needs Angular component architecture guidance unrelated to reactivity (routing, lazy loading, module organization) -- check the angular-architecture skill
- User is asking about NgRx Actions/Reducers/Effects in the traditional Redux pattern -- check the ngrx-redux-patterns skill unless they specifically ask about NgRx SignalStore
- User needs server-side rendering hydration strategies in Angular -- signals interact with SSR in specific ways covered by the angular-ssr skill
Process
1. Identify the Reactivity Scope and State Ownership
Before writing any signal code, classify what kind of state is being managed and where it lives.
- Local component state -- data that only one component needs, never shared upward or across routes. Use
signal()directly in the component class. No service injection needed. - Shared feature state -- data shared between sibling or parent/child components within one route or feature area. Use an injectable service with
signal()properties, scoped withprovidedIn: 'root'or a feature-levelprovidersarray. - Global application state -- user session, auth tokens, app configuration, notifications. Use a dedicated signal-based store class, either hand-rolled or via NgRx SignalStore.
- Derived/computed state -- anything that is a pure function of other signals. Always use
computed()-- never store derived state in a writable signal and keep it synchronized witheffect(). - Ask the user: "Is this state read by more than one component? Does it need to survive navigation? Does it need undo/redo or devtools inspection?" These answers determine the appropriate pattern tier.
2. Define the Signal Graph Structure
Design the signal dependency graph before implementation to avoid creating brittle or cyclical dependencies.
- Draw the full state as a directed acyclic graph (DAG): writable signals are leaf inputs,
computed()signals are intermediate nodes, template bindings andeffect()calls are terminal sinks. - Identify which signals are "source of truth" (
WritableSignal<T>) vs. derived projections (Signal<T>fromcomputed()). - Keep the graph shallow: chains longer than 3--4 levels of
computed()calls become difficult to debug and can introduce latency in recomputation during a single change detection cycle. - Never make a computed signal depend on a
WritableSignalthat the computed signal's consumer also writes to -- this creates a logical cycle that Angular's glitch-free algorithm cannot resolve cleanly. - For collections, decide whether the signal holds the entire array (
signal<Item[]>([])) or a map (signal<Map<string, Item>>(new Map())) based on how updates will be performed. Replacing the entire array on every mutation is correct but causescomputed()consumers of individual items to recompute even when unrelated items change.
3. Implement Writable Signals with Correct Mutation Patterns
Signal mutations must be performed correctly to trigger reactivity and avoid stale references.
- Use
signal<T>(initialValue)with an explicit generic type annotation always -- do not rely on inference when the initial value isnull,undefined, or an empty array, as inference will produce too-narrow types.// BAD: infers signal<never[]> const items = signal([]); // GOOD const items = signal<Item[]>([]); - Use
.set(newValue)when replacing the entire value. - Use
.update(prev => newValue)when the new value depends on the previous value -- this is the correct pattern for array mutations and counter increments.items.update(prev => [...prev, newItem]); // append items.update(prev => prev.filter(i => i.id !== id)); // remove count.update(n => n + 1); // increment - Never mutate the signal's value in place (e.g.,
items().push(x)) -- this does not notify Angular's reactive graph and produces stale UI. - Use
.mutate()only if available in your Angular version (it was removed in Angular 17.1 in favor of always-immutable updates with.update()). If the codebase is on 16.x,.mutate()exists but signals the team to plan for removal. - For deep objects, use a custom equality function to prevent re-renders when semantically identical objects are produced:
Useconst config = signal<Config>(initialConfig, { equal: (a, b) => JSON.stringify(a) === JSON.stringify(b) });JSON.stringifyonly for small, serializable objects. For large objects, write a structural comparator or use a library likefast-deep-equal.
4. Build Computed Signals for Derived State
computed() is the most powerful primitive for keeping derived state in sync without explicit subscription management.
- Every
computed()call creates a memoized, lazy value. It only recomputes when at least one of its signal dependencies has changed since the last read. - Express computed signals as pure functions -- no HTTP calls, no DOM manipulation, no logging inside
computed(). Those belong ineffect().readonly filteredItems = computed(() => this.items().filter(item => item.active && item.category === this.selectedCategory()) ); readonly totalPrice = computed(() => this.cartItems().reduce((sum, item) => sum + item.price * item.quantity, 0) ); - Use
computed()for template-facing boolean flags:readonly isLoading = computed(() => this.status() === 'loading'). This is cleaner than storing a separateisLoadingwritable signal. - Computed signals are not writable -- if you find yourself wanting to write to a computed value, you have identified a missing writable signal that the computed should depend on.
- Computed signals are synchronous. They cannot await Promises or subscribe to Observables. For async derived values, use
toSignal()wrapping an Observable pipeline, or the experimentalresource()API in Angular 18+. - Avoid computeds that depend on more than 6--8 distinct signals -- this is a code smell indicating the component or service has too many responsibilities. Split the state.
5. Use effect() Correctly for Side Effects
effect() is the most commonly misused signal API. Apply it with discipline.
effect()runs once immediately after creation and again whenever any signal it reads changes. It runs inside the Angular change detection context.- Legitimate uses for
effect():- Synchronizing signal state to
localStorageorsessionStorage - Logging/analytics when specific signals change
- Integrating with imperative third-party libraries (e.g., setting a chart library's data when a signal changes)
- Triggering router navigation based on auth state changes
- Synchronizing signal state to
- Illegitimate uses that indicate a design problem:
- Writing to a signal inside
effect()to keep two signals synchronized -- usecomputed()instead - Making HTTP calls inside
effect()-- use an Observable orresource()API instead - Replacing
ngOnChangeswitheffect()-- use signal-basedinput()withcomputed()instead
- Writing to a signal inside
- To write to a signal inside an
effect()when truly necessary (rare), use theallowSignalWritesoption:
This option exists to escape hatches, not as a default pattern. If you use it more than once per class, reconsider the design.effect(() => { const value = this.externalSignal(); this.localSignal.set(processValue(value)); }, { allowSignalWrites: true }); - Always clean up
effect()by using theDestroyRefinjection or calling the returned cleanup function. In components and directives, effects registered in the constructor are cleaned up automatically on destroy. In services, useinject(DestroyRef).onDestroy()to clean up manually created effects. - Never create
effect()calls outside of an injection context (constructor, field initializer, or a function called during injection) without explicitly providing an injector.
6. Wire Signal-Based Component Inputs and Outputs
Angular 17.1+ introduced signal-based input(), output(), and model() APIs that replace @Input(), @Output(), and @Input()/@Output() pairs.
- Use
input<T>()for required inputs andinput<T>(defaultValue)for optional inputs with defaults:readonly userId = input.required<string>(); // throws if not provided readonly pageSize = input<number>(25); // optional, defaults to 25 - Signal inputs are
InputSignal<T>, which is a read-onlySignal<T>. Use them incomputed()and templates exactly like writable signals:this.userId(). - Use
model<T>()for two-way binding -- it creates aModelSignal<T>that is both readable and writable, generating an implicit(valueChange)event output:readonly selectedDate = model<Date | null>(null); // Parent binds with [(selectedDate)]="parentDate" // Child updates with: this.selectedDate.set(newDate) - Use
output<T>()for event emissions -- it replacesEventEmitterand is not a signal but anOutputEmitterRef:readonly itemSelected = output<Item>(); // Emit with: this.itemSelected.emit(item) - Never mix
@Input()decorators withinput()signals in the same component -- it is confusing and produces maintenance problems. Migrate the entire component at once. input()signals are not writable from inside the component -- only the parent can change them. If you need writable internal state that mirrors an input, derive it:readonly internalValue = signal(this.externalInput()).
7. Implement Signal-Based Service Stores
For feature or global state, implement a signal store as a plain injectable service or using NgRx SignalStore.
Hand-rolled signal store pattern:
@Injectable({ providedIn: 'root' })
export class CartStore {
// Private writable signals -- only this store mutates state
private readonly _items = signal<CartItem[]>([]);
private readonly _status = signal<'idle' | 'loading' | 'error'>('idle');
// Public read-only projections
readonly items: Signal<CartItem[]> = this._items.asReadonly();
readonly status: Signal<'idle' | 'loading' | 'error'> = this._status.asReadonly();
// Computed selectors
readonly totalItems = computed(() => this._items().reduce((sum, i) => sum + i.quantity, 0));
readonly totalPrice = computed(() => this._items().reduce((sum, i) => sum + i.price * i.quantity, 0));
readonly isEmpty = computed(() => this._items().length === 0);
// Commands (methods that mutate state)
addItem(item: CartItem): void {
this._items.update(items => {
const existing = items.find(i => i.productId === item.productId);
if (existing) {
return items.map(i => i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...items, item];
});
}
removeItem(productId: string): void {
this._items.update(items => items.filter(i => i.productId !== productId));
}
clearCart(): void {
this._items.set([]);
}
}
- Always expose state as
.asReadonly()signals from services -- components should never directly mutate service-owned signals. - Group store methods into "commands" (state mutations) and "queries" (computed projections). Commands are methods; queries are
computed()properties. - For async operations, integrate with
toSignal()and manage loading/error state explicitly:loadItems(): void { this._status.set('loading'); this.http.get<CartItem[]>('/api/cart').pipe( takeUntilDestroyed(this.destroyRef) ).subscribe({ next: items => { this._items.set(items); this._status.set('idle'); }, error: () => this._status.set('error') }); }
8. Integrate Signals with RxJS Using Interop APIs
Signals and Observables coexist in Angular -- know the correct bridge for each direction.
- Observable to Signal -- use
toSignal(observable$, options):
Always providereadonly searchResults = toSignal( this.searchQuery$.pipe( debounceTime(300), distinctUntilChanged(), switchMap(query => this.searchService.search(query)) ), { initialValue: [] } // avoids Signal<T | undefined> );initialValueunless the observable is guaranteed to emit synchronously, becausetoSignal()withoutinitialValuecreatesSignal<T | undefined>and causes undefined-access errors in templates. - Signal to Observable -- use
toObservable(signal):readonly results$ = toObservable(this.searchQuery).pipe( debounceTime(300), switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)) );toObservable()useseffect()internally and emits on each signal change. It must be called in an injection context. - For HTTP calls, prefer keeping the Observable pipeline and exposing the result as a signal via
toSignal(). Do not convert HTTP Observables into Promises just to use with signals -- the Observable pipeline gives you operators likeswitchMap,catchError, andretrythat are difficult to replicate imperatively. - The experimental
resource()API in Angular 18+ handles the async signal use case natively:readonly userResource = resource({ request: () => ({ id: this.userId() }), loader: ({ request }) => fetch(`/api/users/${request.id}`).then(r => r.json()) }); // Access: this.userResource.value(), this.userResource.isLoading(), this.userResource.error()
Output Format
When advising on Angular Signals patterns, produce output in this structure:
## Signal Architecture Assessment
### State Classification
| State Slice | Type | Owner | Pattern |
|-------------------|--------------|------------------|------------------|
| [state name] | local/shared/global | component/service/store | signal/computed/toSignal |
### Signal Graph Diagram (text representation)
[WritableSignal] --> [computed] --> [template binding]
--> [computed] --> [effect (side effect)]
### Implementation
#### Signal Definitions
// Writable signals with types
// Computed signals
// Effects (with justification)
#### Service Store (if applicable)
// Full store class with private writable, public readonly, computed selectors, command methods
#### Interop Layer (if mixing RxJS)
// toSignal() / toObservable() calls with options
### Migration Path (if replacing existing code)
Phase 1: [specific migration step -- which component or service to migrate first]
Phase 2: [next migration step]
Phase 3: [cleanup and removal of old patterns]
### Tradeoffs Accepted
- [specific tradeoff and why it is acceptable for this context]
Rules
-
NEVER declare a
signal()without an explicit TypeScript generic type annotation when the initial value could be misread by inference -- especially fornull,undefined, empty arrays, and union types. Missing generics cause runtime undefined-access errors that TypeScript does not catch at compile time. -
NEVER write to a signal inside a
computed()callback -- Angular's glitch-free algorithm prevents this, and attempting it throws a runtime error. If you find yourself wanting to, you need to restructure the graph so the writable signal is an input and the computed is the output. -
ALWAYS call
effect()andtoSignal()inside an injection context (constructor, field initializer,runInInjectionContext()). Calling them outside throwsNG0203: inject() must be called from an injection context. This is the most common runtime error when adopting signals. -
NEVER expose a
WritableSignaldirectly from a service -- always use.asReadonly()to return aSignal<T>. Components that can freely mutate service state break encapsulation and make bugs impossible to trace. -
ALWAYS provide
initialValuetotoSignal()unless the source Observable is guaranteed to emit synchronously (e.g.,of(value),from([]), or a BehaviorSubject). WithoutinitialValue, the resulting type isSignal<T | undefined>and templates will throw on first render. -
NEVER use
effect()to synchronize two writable signals (keepingsignalAupdated wheneversignalBchanges). This is acomputed()use case. Usingeffect()for this creates asynchronous update behavior that causes the UI to flash intermediate states. -
NEVER create signal-based stores with mutable public state properties. The pattern
readonly items = signal<Item[]>([])on a service is wrong because any injector can call.set()or.update(). Always private the writable signal and expose.asReadonly(). -
ALWAYS unsubscribe or clean up
toObservable()and manually createdeffect()calls in services. Services provided at root live for the application lifetime, but feature-scoped services may be destroyed. UsetakeUntilDestroyed(this.destroyRef)on Observable chains derived from signals. -
NEVER mix
@Input()decorator-based inputs with signalinput()on the same component property. The two systems are not compatible for the same binding. Migrate the entire component's inputs at once to avoid confusing the Angular compiler and template type checker. -
ALWAYS prefer
computed()over storing derived state in a writable signal. If you see a pattern wherengOnChangesor aneffect()sets one signal based on another, that derived signal should be acomputed(). Redundant writable signals for derived data become inconsistent when update paths are missed. -
NEVER use
JSON.stringifyas an equality comparator for signals that hold large objects or objects with circular references -- it is O(n) on every change detection cycle and will throw on circular structures. Write a structural comparator or usefast-deep-equalfor complex objects. -
ALWAYS test computed signal logic in isolation using
TestBed.runInInjectionContext()or by instantiating the store/service directly in unit tests. Angular's signal primitives work in test environments -- there is no need to mock them.
Edge Cases
Legacy RxJS-Heavy Codebase Migration
When introducing signals to a codebase built on BehaviorSubject + async pipe patterns, do not attempt to rewrite all reactive state at once.
- Start at the leaf components -- components that only consume state and never share it upward. Replace their local
BehaviorSubjectproperties withsignal(). - Use
toSignal()at the component boundary to convert incoming service Observables to signals for template consumption, without touching the service itself. - Migrate services only after all consumers have been updated to use signal-based APIs. The intermediate state -- services emitting Observables, components converting with
toSignal()-- is stable and production-safe. - Expect a 3--6 month migration timeline for a 50+ component application. Do not set a deadline that forces a rushed migration -- a partially migrated codebase with
toSignal()bridges is correct and maintainable.
Signals Inside OnPush Change Detection Components
Angular Signals are deeply integrated with the Zoneless / OnPush change detection model.
- Signals read in a template automatically schedule a check for that component when their value changes -- this works correctly with both default and
OnPushchange detection. - With
OnPush, if you read a signal inside a method called from a template event handler, the signal read does not register a reactive dependency because it happens outside the template rendering pass. Always surface signal values through the template directly ({{ mySignal() }}) or throughcomputed()properties accessed in the template. - Computed signals accessed only in
OnPushcomponent methods (not templates) do not trigger change detection. This is a common source of "stale UI" bugs when migrating from RxJS. - When using Zoneless Angular (experimental in Angular 18+), signals are the primary change detection trigger. In this mode,
setTimeout,setInterval, and Promise callbacks do NOT trigger change detection. Any async result must be stored back into a signal to update the UI.
Signal Equality and Unnecessary Recomputation
Angular compares signal values with Object.is() by default. This causes issues with non-primitive values.
- Primitive values (
string,number,boolean) work correctly withObject.is()-- a signal holding42updated to42does NOT trigger recomputation. - Reference types (arrays, objects) compared with
Object.is()will trigger recomputation even if the contents are identical, because a new reference is always "not equal" to the previous reference. - When using
.update()on an array to produce a filtered or mapped copy, every downstreamcomputed()will recompute even if the filter result is identical to the previous value. Add a customequalfunction to the signal definition to prevent this:const ids = signal<string[]>([], { equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) }); - Custom equality functions must be pure, fast, and side-effect-free. They run during every
.set()and.update()call.
Signals in Angular Route Resolvers and Guards
Route resolvers and guards in Angular 17+ can use signals via inject() and toSignal(), but with important constraints.
- Route resolvers must return a value synchronously or as a Promise/Observable. They cannot return a
Signal<T>directly. UsefirstValueFrom(toObservable(signal))if you need to expose signal-derived data to a resolver. - Guards using signal-based auth stores should use
computed()to derive the guard condition, then convert to an Observable for the router:
This works synchronously becauseexport const authGuard = () => { const authStore = inject(AuthStore); return authStore.isAuthenticated() || router.createUrlTree(['/login']); };isAuthenticated()reads a signal synchronously. - Avoid
toObservable()in guards unless the auth state legitimately needs to resolve asynchronously (e.g., waiting for an HTTP check). Most signal-based guards should return synchronously.
NgRx SignalStore Integration
NgRx SignalStore (introduced in NgRx 17) is the library-supported signal store pattern and has specific conventions.
signalStore()creates a class-based store usingwithState(),withComputed(),withMethods(), andwithHooks()feature functions.- State defined with
withState()is automatically made available as individual deep signals -- a state property{ user: { name: 'Alice' } }becomes accessible asstore.user()andstore.user.name(). - Use
patchState(store, partial)insidewithMethods()to mutate state -- never use the internal signal directly. - NgRx SignalStore's
withEntities()feature from@ngrx/signals/entitiesprovides a complete entity management solution with signals, including selectors forids,entities,entityMap, and methods likeaddEntity,updateEntity,removeEntity. Prefer this over hand-rolling entity CRUD in signal arrays. - SignalStore methods can use
rxMethod()to handle Observable-based side effects (HTTP calls, debouncing) while keeping the store's state management in signals. This is the recommended pattern for async operations in NgRx SignalStore.
Performance Degradation with Large Signal Graphs
When a single user action triggers updates across dozens of computed signals, the change detection overhead can become noticeable.
- Angular's signal runtime processes updates in topological order (glitch-free) -- a single change does not cause intermediate dirty states to be read. However, if 40+
computed()signals all depend on one root signal, all 40 recompute synchronously before the next render. - Profile signal overhead using Angular DevTools' "change detection" tab. If a single user interaction shows 50ms+ in signal recomputation before rendering, the graph is too wide.
- Restructure wide graphs by introducing intermediate signals that group related computations: instead of 20 computeds reading
allItems(), introduceactiveItems = computed(() => allItems().filter(i => i.active))and have the 20 derivations readactiveItems(). - For very large lists (1000+ items), consider whether a signal should hold the entire list or an index/map structure. Filtering a
signal<Item[]>of 10,000 items in acomputed()that runs on every keystroke is a real performance problem. Move filtering to the server or use virtual scrolling.
Testing Signal-Based Components and Services
Signal primitives work in TestBed, but there are testing patterns specific to signals.
TestBed.runInInjectionContext(() => { ... })allows you to create signals, effects, and calltoSignal()/toObservable()in tests without a component fixture.- To test that a
computed()reacts correctly, set the dependency signal's value and immediately read the computed -- no async tick is needed because computed values are synchronously derived:store.setItems([{ id: '1', active: true }, { id: '2', active: false }]); expect(store.activeItems()).toHaveLength(1); - To test
effect()side effects, callTestBed.flushEffects()after setting signal values -- effects are batched and do not run synchronously. Without flushing, effect-driven behavior will not appear in test assertions. - Use
jasmine.createSpyObjorjest.fn()on the external system that theeffect()calls (e.g.,localStorage.setItem) and assert afterTestBed.flushEffects().
Example
Input: "I'm building an e-commerce product listing page in Angular 17. I have a ProductService that fetches products via HTTP. The page has search, category filter, sort, and pagination. Multiple child components display the filtered results, loading state, and pagination controls. What's the correct signals architecture?"
Output:
Signal Architecture Assessment
State Classification
| State Slice | Type | Owner | Pattern |
|---|---|---|---|
searchQuery |
local UI state | ProductListStore |
signal<string>('') |
selectedCategory |
local UI state | ProductListStore |
signal<string | null>(null) |
sortOrder |
local UI state | ProductListStore |
signal<SortOrder>('relevance') |
currentPage |
local UI state | ProductListStore |
signal<number>(1) |
rawProducts |
server data | ProductListStore |
toSignal(http$, { initialValue: [] }) |
filteredProducts |
derived | ProductListStore |
computed() |
totalPages |
derived | ProductListStore |
computed() |
isLoading |
derived from Observable | ProductListStore |
signal<boolean>(false) |
error |
derived from Observable | ProductListStore |
signal<string | null>(null) |
Signal Graph
searchQuery ──────┐
selectedCategory ─┼──> [filterParams: computed] ──> HTTP trigger (toObservable)
sortOrder ────────┤ │
currentPage ──────┘ rawProducts (toSignal)
│
filteredProducts (computed)
│
┌───────────┴────────────┐
template bindings totalPages (computed)
(product-grid, pagination)
Implementation
// product-list.store.ts
import { Injectable, computed, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap, catchError, tap, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
export type SortOrder = 'relevance' | 'price-asc' | 'price-desc' | 'newest';
interface FilterParams {
query: string;
category: string | null;
sort: SortOrder;
page: number;
}
export interface Product {
id: string;
name: string;
price: number;
category: string;
imageUrl: string;
}
@Injectable() // NOT providedIn: 'root' -- scoped to the feature route
export class ProductListStore {
private readonly http = inject(HttpClient);
// ── Writable source signals (private -- only this store mutates them) ──
private readonly _searchQuery = signal<string>('');
private readonly _selectedCategory = signal<string | null>(null);
private readonly _sortOrder = signal<SortOrder>('relevance');
private readonly _currentPage = signal<number>(1);
private readonly _isLoading = signal<boolean>(false);
private readonly _error = signal<string | null>(null);
// ── Public read-only projections ──
readonly searchQuery = this._searchQuery.asReadonly();
readonly selectedCategory = this._selectedCategory.asReadonly();
readonly sortOrder = this._sortOrder.asReadonly();
readonly currentPage = this._currentPage.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly error = this._error.asReadonly();
// ── Computed filter params (collapses 4 signals into one fetch trigger) ──
private readonly filterParams = computed<FilterParams>(() => ({
query: this._searchQuery(),
category: this._selectedCategory(),
sort: this._sortOrder(),
page: this._currentPage(),
}));
// ── HTTP trigger: filterParams Observable drives product fetching ──
// toObservable converts the computed signal into a stream
// debounceTime(300) prevents fetching on every keystroke
private readonly products$ = toObservable(this.filterParams).pipe(
debounceTime(300),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
tap(() => {
this._isLoading.set(true);
this._error.set(null);
}),
switchMap(params =>
this.http.get<{ products: Product[]; total: number }>(
'/api/products',
{ params: this.buildHttpParams(params) }
).pipe(
catchError(err => {
this._error.set('Failed to load products. Please try again.');
this._isLoading.set(false);
return of({ products: [], total: 0 });
})
)
),
tap(() => this._isLoading.set(false))
);
// ── Convert HTTP Observable to a signal for template consumption ──
private readonly productResponse = toSignal(
this.products$,
{ initialValue: { products: [], total: 0 } }
);
// ── Public computed selectors ──
readonly products = computed(() => this.productResponse().products);
readonly totalResults = computed(() => this.productResponse().total);
readonly totalPages = computed(() => Math.ceil(this.totalResults() / 24)); // 24 per page
readonly hasResults = computed(() => this.products().length > 0);
readonly isEmpty = computed(() =>
!this.isLoading() && !this.error() && !this.hasResults()
);
// ── Commands (state mutation methods) ──
setSearchQuery(query: string): void {
this._searchQuery.set(query.trim());
this._currentPage.set(1); // reset pagination on search change
}
setCategory(category: string | null): void {
this._selectedCategory.set(category);
this._currentPage.set(1);
}
setSortOrder(sort: SortOrder): void {
this._sortOrder.set(sort);
this._currentPage.set(1);
}
goToPage(page: number): void {
const total = this.totalPages();
if (page < 1 || page > total) return; // guard invalid page numbers
this._currentPage.set(page);
}
private buildHttpParams(params: FilterParams): Record<string, string> {
const result: Record<string, string> = {
q: params.query,
sort: params.sort,
page: String(params.page),
pageSize: '24',
};
if (params.category) result['category'] = params.category;
return result;
}
}
// product-list.component.ts
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { ProductListStore, SortOrder } from './product-list.store';
@Component({
selector: 'app-product-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // signals work with OnPush
providers: [ProductListStore], // feature-scoped -- one instance per route
template: `
<app-search-bar
[value]="store.searchQuery()"
(search)="store.setSearchQuery($event)"
/>
<app-category-filter
[selected]="store.selectedCategory()"
(categoryChange)="store.setCategory($event)"
/>
<app-sort-selector
[current]="store.sortOrder()"
(sortChange)="store.setSortOrder($event)"
/>
@if (store.isLoading()) {
<app-loading-skeleton [rows]="8" />
} @else if (store.error()) {
<app-error-message [message]="store.error()!" />
} @else if (store.isEmpty()) {
<app-empty-state [query]="store.searchQuery()" />
} @else {
<app-product-grid [products]="store.products()" />
<app-pagination
[currentPage]="store.currentPage()"
[totalPages]="store.totalPages()"
(pageChange)="store.goToPage($event)"
/>
<p class="results-count">{{ store.totalResults() }} results</p>
}
`
})
export class ProductListComponent {
readonly store = inject(ProductListStore);
}
// product-list.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductListStore } from './product-list.store';
describe('ProductListStore', () => {
let store: ProductListStore;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ProductListStore],
});
store = TestBed.inject(ProductListStore);
httpMock = TestBed.inject(HttpTestingController);
});
it('should reset to page 1 when search query changes', () => {
store.goToPage(3);
expect(store.currentPage()).toBe(3);
store.setSearchQuery('shoes');
expect(store.currentPage()).toBe(1);
expect(store.searchQuery()).toBe('shoes');
});
it('should compute isEmpty correctly', () => {
// Before any HTTP response, isLoading is true, so isEmpty should be false
expect(store.isEmpty()).toBe(false);
});
});
Migration Path (from existing BehaviorSubject-based component)
Phase 1 (Week 1): Add ProductListStore service alongside the existing component. Do not change the existing component yet. Validate the store logic in isolation with unit tests.
Phase 2 (Week 2): Replace the existing component's template with signal-based bindings, using toSignal() to bridge any remaining Observable dependencies from parent services that have not yet been migrated.
Phase 3 (Week 3): Remove the old BehaviorSubject properties and async pipes from the component. Remove ngOnInit Observable subscriptions. Delete the old manual subscription teardown logic in ngOnDestroy.
Tradeoffs Accepted
- Using
JSON.stringifyfordistinctUntilChangedonfilterParamsis acceptable here because the filter params object is small (4 fields, all primitives) and the comparator only runs on debounced emissions -- not on every signal change. - The store is scoped to the route via
providers: [ProductListStore]rather thanprovidedIn: 'root', meaning the HTTP request is cancelled and state is reset on every navigation away from the page. This is the correct behavior for a search page but would be wrong for a persistent shopping cart -- which should useprovidedIn: 'root'and explicit clear commands. ChangeDetectionStrategy.OnPushis mandatory alongside signals for performance -- without it, Angular runs full change detection on every event even though signals provide precise dirty tracking. Any component that consumes signals from this store should also useOnPush.