# Kramme:connect Migrate Legacy Store To Ngrx Component Store

> Use this Skill when working in the Connect monorepo and needing to migrate legacy CustomStore or FeatureStore implementations to NgRx ComponentStore.

- Skill: `majiayu000/kramme-connect-migrate-legacy-store-to-ngrx-component-store` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add majiayu000/kramme-connect-migrate-legacy-store-to-ngrx-component-store`
- Raw SKILL.md: https://api.skillmd.com/api/skills/majiayu000/kramme-connect-migrate-legacy-store-to-ngrx-component-store/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: majiayu000 (https://skillmd.com/u/majiayu000)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/majiayu000/kramme-connect-migrate-legacy-store-to-ngrx-component-store

---


# 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<StateInterface>`
-   **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<EventStoreState> {
  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<void>(
  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<Event>('AddEvent'));
  this.updateEvent(this.#wsService.action<Event>('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<PayloadType>` - wire observables directly

**EXAMPLE:**
```typescript
// Updaters accept PayloadType | Observable<PayloadType>
readonly setEvents = this.updater<Event[]>((state, events) => ({
  ...state,
  events,
}));

readonly addEvent = this.updater<Event>((state, event) => ({
  ...state,
  events: [...state.events, event],
}));

readonly updateEvent = this.updater<Event>((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<boolean>((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<Type>(pipe(...))` instead of `this.effect<Type>((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<Event>(
  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<Item>('AddItem'));
  this.updateItem(this.#wsService.action<Item>('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/<product>/<application>/<domain>/<type>-<name>`
    -   **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.

