--- name: angular-store description: Use when implementing state management with PlatformVmStore for complex components requiring reactive state, effects, and selectors. allowed-tools: Read, Write, Edit, Grep, Glob, Bash --- # Angular Store Development Workflow ## When to Use This Skill - List components with CRUD operations - Complex state with multiple data sources - Shared state between components - Caching and reloading patterns ## Pre-Flight Checklist - [ ] Identify state shape (what data is needed) - [ ] **Read the design system docs** for the target application (see below) - [ ] Identify side effects (API calls, etc.) - [ ] Search similar stores: `grep "{Feature}Store" --include="*.ts"` - [ ] Determine caching requirements ## 🎨 Design System Documentation (MANDATORY) **Before creating any store, read the design system documentation for your target application:** | Application | Design System Location | | --------------------------------- | ------------------------------------------------ | | **WebV2 Apps** | `docs/design-system/` | | **TextSnippetClient** | `src/PlatformExampleAppWeb/apps/playground-text-snippet/docs/design-system/` | **Key docs to read:** - `README.md` - Component overview, base classes, library summary - `06-state-management.md` - State management patterns (NgRx, PlatformVmStore) - `07-technical-guide.md` - Implementation checklist, best practices ## File Location ``` src/PlatformExampleAppWeb/apps/{app-name}/src/app/ └── features/ └── {feature}/ ├── {feature}.store.ts └── {feature}.component.ts ``` ## Store Architecture ``` PlatformVmStore ├── State: TState (reactive signal) ├── Selectors: select() → Signal ├── Effects: effectSimple() → side effects ├── Updaters: updateState() → mutations └── Loading/Error: observerLoadingErrorState() ``` ## Pattern 1: Basic CRUD Store ```typescript // {feature}-list.store.ts import { Injectable } from '@angular/core'; import { PlatformVmStore } from '@libs/platform-core'; // ═══════════════════════════════════════════════════════════════════════════ // STATE INTERFACE // ═══════════════════════════════════════════════════════════════════════════ export interface FeatureListState { items: FeatureDto[]; selectedItem?: FeatureDto; filters: FeatureFilters; pagination: PaginationState; } export interface FeatureFilters { searchText?: string; status?: FeatureStatus[]; dateRange?: DateRange; } export interface PaginationState { pageIndex: number; pageSize: number; totalCount: number; } // ═══════════════════════════════════════════════════════════════════════════ // STORE IMPLEMENTATION // ═══════════════════════════════════════════════════════════════════════════ @Injectable() export class FeatureListStore extends PlatformVmStore { // ───────────────────────────────────────────────────────────────────────── // CONFIGURATION // ───────────────────────────────────────────────────────────────────────── // State factory protected override vmConstructor = (data?: Partial) => ({ items: [], filters: {}, pagination: { pageIndex: 0, pageSize: 20, totalCount: 0 }, ...data }) as FeatureListState; // Optional: Enable caching protected override get enableCaching() { return true; } protected override cachedStateKeyName = () => 'FeatureListStore'; // ───────────────────────────────────────────────────────────────────────── // SELECTORS (Reactive Signals) // ───────────────────────────────────────────────────────────────────────── public readonly items$ = this.select(state => state.items); public readonly selectedItem$ = this.select(state => state.selectedItem); public readonly filters$ = this.select(state => state.filters); public readonly pagination$ = this.select(state => state.pagination); // Derived selectors public readonly hasItems$ = this.select(state => state.items.length > 0); public readonly isEmpty$ = this.select(state => state.items.length === 0); // ───────────────────────────────────────────────────────────────────────── // EFFECTS (Side Effects) // ───────────────────────────────────────────────────────────────────────── // Load items with current filters public loadItems = this.effectSimple(() => { const state = this.currentVm(); return this.featureApi .getList({ ...state.filters, skipCount: state.pagination.pageIndex * state.pagination.pageSize, maxResultCount: state.pagination.pageSize }) .pipe( this.tapResponse(result => { this.updateState({ items: result.items, pagination: { ...state.pagination, totalCount: result.totalCount } }); }) ); }, 'loadItems'); // Save item (create or update) public saveItem = this.effectSimple( (item: FeatureDto) => this.featureApi.save(item).pipe( this.tapResponse(saved => { this.updateState(state => ({ items: state.items.upsertBy(x => x.id, [saved]), selectedItem: saved })); }) ), 'saveItem' ); // Delete item public deleteItem = this.effectSimple( (id: string) => this.featureApi.delete(id).pipe( this.tapResponse(() => { this.updateState(state => ({ items: state.items.filter(x => x.id !== id), selectedItem: state.selectedItem?.id === id ? undefined : state.selectedItem })); }) ), 'deleteItem' ); // ───────────────────────────────────────────────────────────────────────── // STATE UPDATERS // ───────────────────────────────────────────────────────────────────────── public setFilters(filters: Partial): void { this.updateState(state => ({ filters: { ...state.filters, ...filters }, pagination: { ...state.pagination, pageIndex: 0 } // Reset to first page })); } public setPage(pageIndex: number): void { this.updateState(state => ({ pagination: { ...state.pagination, pageIndex } })); } public selectItem(item?: FeatureDto): void { this.updateState({ selectedItem: item }); } public clearFilters(): void { this.updateState({ filters: {}, pagination: { ...this.currentVm().pagination, pageIndex: 0 } }); } // ───────────────────────────────────────────────────────────────────────── // CONSTRUCTOR // ───────────────────────────────────────────────────────────────────────── constructor(private featureApi: FeatureApiService) { super(); } } ``` ## Pattern 2: Store with Dependent Data ```typescript @Injectable() export class EmployeeFormStore extends PlatformVmStore { protected override vmConstructor = (data?: Partial) => ({ employee: null, departments: [], positions: [], managers: [], ...data }) as EmployeeFormState; // Load all dependent data in parallel public loadFormData = this.effectSimple( (employeeId?: string) => forkJoin({ employee: employeeId ? this.employeeApi.getById(employeeId) : of(this.createNewEmployee()), departments: this.departmentApi.getActive(), positions: this.positionApi.getAll(), managers: this.employeeApi.getManagers() }).pipe( this.tapResponse(result => { this.updateState({ employee: result.employee, departments: result.departments, positions: result.positions, managers: result.managers }); }) ), 'loadFormData' ); // Dependent selector - filter managers by department public managersForDepartment$ = (departmentId: string) => this.select(state => state.managers.filter(m => m.departmentId === departmentId)); } ``` ## Pattern 3: Store with Caching ```typescript @Injectable({ providedIn: 'root' }) // Singleton for caching export class LookupDataStore extends PlatformVmStore { protected override get enableCaching() { return true; } protected override cachedStateKeyName = () => 'LookupDataStore'; // Cache timeout (optional) protected override get cacheExpirationMs() { return 5 * 60 * 1000; } // 5 minutes // Load with cache check public loadCountries = this.effectSimple(() => { if (this.currentVm().countries.length > 0) { return EMPTY; // Already loaded, skip } return this.lookupApi.getCountries().pipe( this.tapResponse(countries => { this.updateState({ countries }); }) ); }, 'loadCountries'); } ``` ## Component Integration ```typescript @Component({ selector: 'app-feature-list', templateUrl: './feature-list.component.html', providers: [FeatureListStore] // Component-scoped store }) export class FeatureListComponent extends AppBaseVmStoreComponent implements OnInit { constructor(store: FeatureListStore) { super(store); } ngOnInit(): void { this.store.loadItems(); } onSearch(text: string): void { this.store.setFilters({ searchText: text }); this.store.loadItems(); } onPageChange(pageIndex: number): void { this.store.setPage(pageIndex); this.store.loadItems(); } onDelete(item: FeatureDto): void { this.store.deleteItem(item.id); } onRefresh(): void { this.reload(); // Inherited - reloads all store effects } // Loading states get isLoading$() { return this.store.isLoading$('loadItems'); } get isSaving$() { return this.store.isLoading$('saveItem'); } get isDeleting$() { return this.store.isLoading$('deleteItem'); } } ``` ## Template Usage ```html @if (vm(); as vm) {
@for (item of vm.items; track item.id) {
{{ item.name }}
} @empty {
No items found
} }
``` ## Key Store APIs | Method | Purpose | Example | | ----------------------------- | -------------------- | --------------------------------------------------------- | | `select()` | Create selector | `this.select(s => s.items)` | | `updateState()` | Update state | `this.updateState({ items })` | | `effectSimple()` | Create effect | `this.effectSimple(() => api.call(), 'requestKey')` | | `currentVm()` | Get current state | `const state = this.currentVm()` | | `observerLoadingErrorState()` | Track loading/error | Use outside effectSimple only (effectSimple handles this) | | `tapResponse()` | Handle success/error | `.pipe(this.tapResponse(success, error))` | | `isLoading$()` | Loading signal | `this.store.isLoading$('loadItems')` | ## Anti-Patterns to AVOID :x: **Calling effects without tracking** ```typescript // WRONG - no loading state this.api.getItems().subscribe(items => this.updateState({ items })); // CORRECT - effectSimple auto-tracks loading state via second parameter public loadItems = this.effectSimple( () => this.api.getItems().pipe( this.tapResponse(items => this.updateState({ items })) ), 'loadItems' ); ``` :x: **Mutating state directly** ```typescript // WRONG - direct mutation this.currentVm().items.push(newItem); // CORRECT - immutable update this.updateState(state => ({ items: [...state.items, newItem] })); ``` :x: **Using store without provider** ```typescript // WRONG - no provider export class MyComponent { constructor(private store: FeatureStore) { } // Error: No provider } // CORRECT - provide at component level @Component({ providers: [FeatureStore] }) ``` ## Verification Checklist - [ ] State interface defines all required properties - [ ] `vmConstructor` provides default state - [ ] Effects use `effectSimple()` with request key as second parameter - [ ] Effects use `tapResponse()` for handling - [ ] Selectors are memoized with `select()` - [ ] State updates are immutable - [ ] Store provided at correct level (component vs root) - [ ] Caching configured if needed