Claude-skill-registry 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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/kramme-connect-migrate-legacy-store-to-ngrx-component-store" ~/.claude/skills/majiayu000-claude-skill-registry-kramme-connect-migrate-legacy-store-to-ngrx-com-6c919d && rm -rf "$T"
skills/data/kramme-connect-migrate-legacy-store-to-ngrx-component-store/SKILL.mdConnect - 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
orCustomStore
to modern NgRx ComponentStoreFeatureStore - You see patterns like
oraddApiAction().withReducer()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
for stores that need application-wide singleton behaviorprovidedIn: 'root' - ALWAYS define state as interface/type with
propertiesreadonly - ALWAYS extract
to a constant; use eager initialization in the constructorinitialState - ALWAYS end class names with a
suffixStore - ALWAYS have file names for Component Stores include
.store.ts - PREFER flat state structures to avoid nested objects in state
EXAMPLE - Before (Legacy):
export const eventStore = new FeatureStore('event') .addApiAction('loadEvents') .withReducer((state, events) => ({ ...state, events }));
EXAMPLE - After (ComponentStore):
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
patterns with ComponentStore updaters and effectsaddApiAction().withReducer() - ALWAYS replace
with updaters that accept observablesaddSocketAction().withReducer() - ALWAYS wire websocket observables directly to updaters in the constructor (no manual subscriptions needed)
- ALWAYS use
fromtapResponse
(not@ngrx/operators
) for effect error handling@ngrx/component-store - NOTE: ComponentStore handles subscriptions automatically
EXAMPLE - Replace API Actions with Effects:
// 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:
// 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
orsetState
)patchState - ALWAYS use
prefix for updaters that replace entire state slicesset - ALWAYS keep state transformations pure and predictable
- NOTE: Updaters can accept
- wire observables directlyPayloadType | Observable<PayloadType>
EXAMPLE:
// 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
— always read via selectorsComponentStore.get() - 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
in selectorstapResponse
EXAMPLE:
// 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
nested in inner pipes (aftertapResponse
/switchMap
)mergeMap - ALWAYS use the RxJS
operator directly in effects:pipe
instead ofthis.effect<Type>(pipe(...))this.effect<Type>((trigger$) => trigger$.pipe(...)) - ALWAYS use
for effects that should cancel previous requestsswitchMap - NEVER subscribe directly to form controls or observables inside components; wire them into store effects
- NEVER provide an empty observable (e.g.,
) when calling effects without argumentsthis.effectName(of(undefined))- NOTE: The effect creates its own trigger observable internally; use
insteadthis.effectName()
- NOTE: The effect creates its own trigger observable internally; use
- ALWAYS import
fromtapResponse
, not@ngrx/operators@ngrx/component-store
EXAMPLE - Correct import:
import { tapResponse } from '@ngrx/operators';
EXAMPLE - Nested tapResponse pattern:
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
in the store, not in a separate serviceConnectSharedDataAccessWebsocketService - ALWAYS wire websocket action observables directly to updaters in the constructor
- ALWAYS wire connection state to load effects using
andfiltermap - NEVER use
for root-provided storestakeUntilDestroyed- NOTE: ComponentStore handles cleanup automatically for root stores
EXAMPLE:
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
function instead of constructor injectioninject() - ALWAYS place all
calls first in the class as readonly fieldsinject() - ALWAYS use ECMAScript
syntax for private members#privateField - NEVER use the
orpublic
keywords in TypeScriptprivate
EXAMPLE - Components Before:
readonly events$ = this.#store.select(eventSelectors.selectEvents); ngOnInit() { this.#store.dispatch(eventActions.loadEvents()); }
EXAMPLE - Components After:
readonly #eventStore = inject(EventStore); readonly events$ = this.#eventStore.events$; ngOnInit() { this.#eventStore.loadEvents(); }
EXAMPLE - Services Before:
this.#store.dispatch(eventActions.updateEvent({ event }));
EXAMPLE - Services After:
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
injection from components/services only using this storeStore - 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
; prefer standard providersprovideComponentStore
What NOT to Do
- NEVER use
for root-provided storestakeUntilDestroyed- NOTE: ComponentStore handles cleanup automatically; only needed for component-scoped stores
- NEVER use
ComponentStore.get()- ALWAYS read state through selectors; use
in effects for one-off readswithLatestFrom()
- ALWAYS read state through selectors; use
- NEVER create manual subscriptions
- ALWAYS wire observables directly to updaters/effects; let ComponentStore manage subscriptions
- NEVER import
fromtapResponse@ngrx/component-store- ALWAYS import from
:@ngrx/operatorsimport { tapResponse } from '@ngrx/operators';
- ALWAYS import from
- NEVER provide empty observables to effects
- EXAMPLE: Use
notthis.loadEvents()this.loadEvents(of(undefined))
- EXAMPLE: Use
- 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
,connectshared - NOTE: Application:
,cms
,shared
(User-Facing Application)ufa - NOTE: Type:
,data-access
,feature
, etc.ui
- NOTE: Product:
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
to mock dependencies{ provide: Service, useValue: mockService } - ALWAYS use
to verify side effectsjest.spyOn() - CAN use
withpatchState
for test setup only// eslint-disable-next-line no-restricted-syntax - ALWAYS include the class name in
blocks:describe()describe(MyStore.name, () => ...) - ALWAYS write test descriptions that clearly state expected behavior:
it('should...')
EXAMPLE:
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:
- Injected dependencies (
)inject() - Selectors (
)readonly prop$ = this.select(...) - Constructor (wire websockets, connection triggers)
- Effects (
)readonly effectName = this.effect(...) - Updaters (
)readonly setX = this.updater(...) - 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
type; useany
when type is uncertainunknown - ALWAYS use ECMAScript
syntax for encapsulation#privateField - NEVER use the
orpublic
keywords in TypeScript class membersprivate
Angular Components Using Stores
- ALWAYS set
inchangeDetection: ChangeDetectionStrategy.OnPush
decorator@Component - ALWAYS use separate HTML files (do NOT use inline templates)
- ALWAYS place all
calls first in the class as readonly fieldsinject() - ALWAYS place
and@Input
properties second in the class@Output
Templates
- ALWAYS use native control flow (
,@if
,@for
) instead of@switch
,*ngIf
,*ngFor*ngSwitch - ALWAYS use the
directive or*ngrxLet
pipe to handle ObservablesngrxPush- ALWAYS prefer the
pipe overngrxPush
for one-off async bindings in templatesasync - PREFER not using
or*ngrxLet
multiple times for the same Observable; instead assign it to a template variable usingngrxPush@let
- ALWAYS prefer the
Services & Dependency Injection
- ALWAYS use the
function instead of constructor injectioninject() - ALWAYS place all
calls first as private readonly fieldsinject() - ALWAYS use the
option for singleton servicesprovidedIn: 'root' - ALWAYS use
for component-level stores@Component.providers
Before Submitting Code Review
- ALWAYS ensure all affected tests pass locally
- ALWAYS run formatting:
(fromyarn run format
)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.