--- name: kramme:connect-migrate-legacy-store-to-ngrx-component-store description: Use this Skill when working in the Connect monorepo and needing to migrate legacy CustomStore or FeatureStore implementations to NgRx ComponentStore. --- # Connect - Migrate Legacy Store to NgRx ComponentStore ## Instructions **When to use this skill:** - You're working in the Connect monorepo (`Connect/ng-app-monolith/`) - You need to migrate a legacy `CustomStore` or `FeatureStore` to modern NgRx ComponentStore - You see patterns like `addApiAction().withReducer()` or `addSocketAction().withReducer()` - The store uses centralized NgRx Store with feature state slices **Context:** Connect's frontend is migrating from a custom store abstraction built on top of NgRx Store to standalone NgRx ComponentStore services. This provides better encapsulation, simpler testing, and eliminates the need for actions/reducers/selectors boilerplate. ### Guideline Keywords - **ALWAYS** — Mandatory requirement, exceptions are very rare and must be explicitly approved - **NEVER** — Strong prohibition, exceptions are very rare and must be explicitly approved - **PREFER** — Strong recommendation, exceptions allowed with justification - **CAN** — Optional, developer's discretion - **NOTE** — Context, rationale, or clarification - **EXAMPLE** — Illustrative example Strictness hierarchy: ALWAYS/NEVER > PREFER > CAN > NOTE/EXAMPLE --- ### Migration Checklist #### 1. Store Structure Transformation - **ALWAYS** convert the store to a standalone service class extending `ComponentStore` - **ALWAYS** use `providedIn: 'root'` for stores that need application-wide singleton behavior - **ALWAYS** define state as interface/type with `readonly` properties - **ALWAYS** extract `initialState` to a constant; use eager initialization in the constructor - **ALWAYS** end class names with a `Store` suffix - **ALWAYS** have file names for Component Stores include `.store.ts` - **PREFER** flat state structures to avoid nested objects in state **EXAMPLE - Before (Legacy):** ```typescript export const eventStore = new FeatureStore('event') .addApiAction('loadEvents') .withReducer((state, events) => ({ ...state, events })); ``` **EXAMPLE - After (ComponentStore):** ```typescript interface EventStoreState { readonly events: Event[]; readonly isLoading: boolean; } const initialState: EventStoreState = { events: [], isLoading: false, }; @Injectable({ providedIn: 'root' }) export class EventStore extends ComponentStore { constructor() { super(initialState); } } ``` #### 2. State Management Patterns - **ALWAYS** replace `addApiAction().withReducer()` patterns with ComponentStore updaters and effects - **ALWAYS** replace `addSocketAction().withReducer()` with updaters that accept observables - **ALWAYS** wire websocket observables directly to updaters in the constructor (no manual subscriptions needed) - **ALWAYS** use `tapResponse` from `@ngrx/operators` (not `@ngrx/component-store`) for effect error handling - **NOTE**: ComponentStore handles subscriptions automatically **EXAMPLE - Replace API Actions with Effects:** ```typescript // Legacy: addApiAction().withReducer() // New: ComponentStore effect readonly loadEvents = this.effect( pipe( tap(() => this.setLoading(true)), switchMap(() => this.#api.getEvents().pipe( tapResponse({ next: (events) => this.setEvents(events), error: (error) => this.#errorHandler.handle(error), finalize: () => this.setLoading(false), }) ) ) ) ); ``` **EXAMPLE - Replace Socket Actions with Updaters:** ```typescript // Wire websocket observables directly to updaters in constructor constructor() { super(initialState); // Subscribe to websocket actions and wire to updaters this.addEvent(this.#wsService.action('AddEvent')); this.updateEvent(this.#wsService.action('UpdateEvent')); this.removeEvent(this.#wsService.action<{ id: string }>('RemoveEvent')); // Trigger load on websocket connection this.loadEvents( this.#wsService.connectionState$.pipe( filter((state) => state === 'Connected'), map(() => undefined) ) ); } ``` #### 3. Updaters (State Mutations) - **ALWAYS** use updaters to change state (not `setState` or `patchState`) - **ALWAYS** use `set` prefix for updaters that replace entire state slices - **ALWAYS** keep state transformations pure and predictable - **NOTE**: Updaters can accept `PayloadType | Observable` - wire observables directly **EXAMPLE:** ```typescript // Updaters accept PayloadType | Observable readonly setEvents = this.updater((state, events) => ({ ...state, events, })); readonly addEvent = this.updater((state, event) => ({ ...state, events: [...state.events, event], })); readonly updateEvent = this.updater((state, updated) => ({ ...state, events: state.events.map((e) => (e.id === updated.id ? updated : e)), })); readonly removeEvent = this.updater<{ id: string }>((state, { id }) => ({ ...state, events: state.events.filter((e) => e.id !== id), })); readonly setLoading = this.updater((state, isLoading) => ({ ...state, isLoading, })); ``` #### 4. Selectors (State Reads) - **ALWAYS** expose state via selectors, suffix static selectors with `$` - **ALWAYS** prefix parameterized selectors with `select` - **NEVER** use `ComponentStore.get()` — always read via selectors - **ALWAYS** do one-off reads in effects by composing with `withLatestFrom(...)` - **ALWAYS** compute derived state in selectors (do not store derived state) - **NEVER** use `tap`/`tapResponse` in selectors **EXAMPLE:** ```typescript // Replace legacy selectors with ComponentStore selectors readonly events$ = this.select((state) => state.events); readonly isLoading$ = this.select((state) => state.isLoading); // Computed/derived state readonly activeEvents$ = this.select( this.events$, (events) => events.filter((e) => e.isActive) ); ``` #### 5. Effects Best Practices - **ALWAYS** only use `tapResponse` nested in inner pipes (after `switchMap`/`mergeMap`) - **ALWAYS** use the RxJS `pipe` operator directly in effects: `this.effect(pipe(...))` instead of `this.effect((trigger$) => trigger$.pipe(...))` - **ALWAYS** use `switchMap` for effects that should cancel previous requests - **NEVER** subscribe directly to form controls or observables inside components; wire them into store effects - **NEVER** provide an empty observable (e.g., `this.effectName(of(undefined))`) when calling effects without arguments - **NOTE**: The effect creates its own trigger observable internally; use `this.effectName()` instead - **ALWAYS** import `tapResponse` from `@ngrx/operators`, not `@ngrx/component-store` **EXAMPLE - Correct import:** ```typescript import { tapResponse } from '@ngrx/operators'; ``` **EXAMPLE - Nested tapResponse pattern:** ```typescript readonly saveEvent = this.effect( pipe( switchMap((event) => this.#api.saveEvent(event).pipe( tapResponse({ next: (saved) => this.updateEvent(saved), error: (error) => this.#errorHandler.handle(error), }) ) ) ) ); ``` #### 6. Websocket Integration - **ALWAYS** inject `ConnectSharedDataAccessWebsocketService` in the store, not in a separate service - **ALWAYS** wire websocket action observables directly to updaters in the constructor - **ALWAYS** wire connection state to load effects using `filter` and `map` - **NEVER** use `takeUntilDestroyed` for root-provided stores - **NOTE**: ComponentStore handles cleanup automatically for root stores **EXAMPLE:** ```typescript readonly #wsService = inject(ConnectSharedDataAccessWebsocketService); constructor() { super(initialState); // Wire websocket actions directly this.addItem(this.#wsService.action('AddItem')); this.updateItem(this.#wsService.action('UpdateItem')); // Trigger load on connection this.loadItems( this.#wsService.connectionState$.pipe( filter((state) => state === 'Connected'), map(() => undefined) ) ); } ``` #### 7. Update Consumers - **ALWAYS** use the `inject()` function instead of constructor injection - **ALWAYS** place all `inject()` calls first in the class as readonly fields - **ALWAYS** use ECMAScript `#privateField` syntax for private members - **NEVER** use the `public` or `private` keywords in TypeScript **EXAMPLE - Components Before:** ```typescript readonly events$ = this.#store.select(eventSelectors.selectEvents); ngOnInit() { this.#store.dispatch(eventActions.loadEvents()); } ``` **EXAMPLE - Components After:** ```typescript readonly #eventStore = inject(EventStore); readonly events$ = this.#eventStore.events$; ngOnInit() { this.#eventStore.loadEvents(); } ``` **EXAMPLE - Services Before:** ```typescript this.#store.dispatch(eventActions.updateEvent({ event })); ``` **EXAMPLE - Services After:** ```typescript this.#eventStore.saveEvent(event); ``` #### 8. Clean Up Legacy Code - **ALWAYS** remove store registration from feature store config (e.g., `provide-event-store.ts`) - **ALWAYS** remove state slice from feature state interface - **ALWAYS** remove reducer mappings - **ALWAYS** remove legacy action exports (unless maintaining backward compatibility) - **ALWAYS** remove legacy selector exports (unless maintaining backward compatibility) - **ALWAYS** remove `Store` injection from components/services only using this store - **ALWAYS** update tests to use ComponentStore directly --- ### Critical Rules #### Encapsulation - **ALWAYS** use subclassed services (not components) for stores - **ALWAYS** place the subclassed store in a separate file in the same folder as the component - **ALWAYS** use only inherited members inside the store; expose public state via selectors #### Lifecycle - **NEVER** use lifecycle hooks (`OnStoreInit`, `OnStateInit`) - **NEVER** use `provideComponentStore`; prefer standard providers #### What NOT to Do - **NEVER** use `takeUntilDestroyed` for root-provided stores - **NOTE**: ComponentStore handles cleanup automatically; only needed for component-scoped stores - **NEVER** use `ComponentStore.get()` - **ALWAYS** read state through selectors; use `withLatestFrom()` in effects for one-off reads - **NEVER** create manual subscriptions - **ALWAYS** wire observables directly to updaters/effects; let ComponentStore manage subscriptions - **NEVER** import `tapResponse` from `@ngrx/component-store` - **ALWAYS** import from `@ngrx/operators`: `import { tapResponse } from '@ngrx/operators';` - **NEVER** provide empty observables to effects - **EXAMPLE**: Use `this.loadEvents()` not `this.loadEvents(of(undefined))` - **NEVER** keep legacy action/selector exports unless explicitly maintaining backward compatibility - **NEVER** register ComponentStores in feature store configurations --- ### File Organization - **ALWAYS** follow the library naming pattern: `libs////-` - **NOTE**: Product: `academy`, `coaching`, `connect`, `shared` - **NOTE**: Application: `cms`, `shared`, `ufa` (User-Facing Application) - **NOTE**: Type: `data-access`, `feature`, `ui`, etc. **EXAMPLE:** ``` libs/connect/ufa/events/ ├── data-access-event/ │ └── src/ │ ├── lib/ │ │ └── event.store.ts # New ComponentStore │ └── index.ts # Export store └── feature-events/ └── src/ └── lib/ └── event-list/ └── event-list.component.ts # Inject and use store ``` --- ### Testing ComponentStores - **ALWAYS** use TestBed to configure the component store and its dependencies - **ALWAYS** test selectors by subscribing and verifying emitted values - **ALWAYS** test updaters by calling them and verifying state changes via selectors - **ALWAYS** test effects by triggering them and verifying side effects - **ALWAYS** use `{ provide: Service, useValue: mockService }` to mock dependencies - **ALWAYS** use `jest.spyOn()` to verify side effects - **CAN** use `patchState` with `// eslint-disable-next-line no-restricted-syntax` for test setup only - **ALWAYS** include the class name in `describe()` blocks: `describe(MyStore.name, () => ...)` - **ALWAYS** write test descriptions that clearly state expected behavior: `it('should...')` **EXAMPLE:** ```typescript describe(EventStore.name, () => { let store: EventStore; beforeEach(() => { TestBed.configureTestingModule({ providers: [ EventStore, { provide: ApiService, useValue: mockApiService }, ], }); store = TestBed.inject(EventStore); }); it('should load events', (done) => { // Test selectors by subscribing store.events$.pipe(skip(1)).subscribe((events) => { expect(events).toEqual(mockEvents); done(); }); // Trigger effect store.loadEvents(); }); }); ``` --- ### Quick Reference: Member Order - **ALWAYS** order members in ComponentStore classes consistently: 1. Injected dependencies (`inject()`) 2. Selectors (`readonly prop$ = this.select(...)`) 3. Constructor (wire websockets, connection triggers) 4. Effects (`readonly effectName = this.effect(...)`) 5. Updaters (`readonly setX = this.updater(...)`) 6. Private helpers --- ### Additional Best Practices from AGENTS.md - **ALWAYS** check AGENTS.md for for the latest definite best practices #### TypeScript - **ALWAYS** prefer type inference when the type is obvious - **ALWAYS** avoid the `any` type; use `unknown` when type is uncertain - **ALWAYS** use ECMAScript `#privateField` syntax for encapsulation - **NEVER** use the `public` or `private` keywords in TypeScript class members #### Angular Components Using Stores - **ALWAYS** set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator - **ALWAYS** use separate HTML files (do NOT use inline templates) - **ALWAYS** place all `inject()` calls first in the class as readonly fields - **ALWAYS** place `@Input` and `@Output` properties second in the class #### Templates - **ALWAYS** use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` - **ALWAYS** use the `*ngrxLet` directive or `ngrxPush` pipe to handle Observables - **ALWAYS** prefer the `ngrxPush` pipe over `async` for one-off async bindings in templates - **PREFER** not using `*ngrxLet` or `ngrxPush` multiple times for the same Observable; instead assign it to a template variable using `@let` #### Services & Dependency Injection - **ALWAYS** use the `inject()` function instead of constructor injection - **ALWAYS** place all `inject()` calls first as private readonly fields - **ALWAYS** use the `providedIn: 'root'` option for singleton services - **ALWAYS** use `@Component.providers` for component-level stores --- ### Before Submitting Code Review - **ALWAYS** ensure all affected tests pass locally - **ALWAYS** run formatting: `yarn run format` (from `Connect/ng-app-monolith`) - **ALWAYS** run linting: `yarn exec nx affected --targets=lint,test --skip-nx-cache` - **ALWAYS** verify no linting errors are present - **ALWAYS** ensure code follows established patterns as outlined in AGENTS.md ## Examples See Instructions Section for code examples.