Agent skill
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.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/kramme-connect-migrate-legacy-store-to-ngrx-component-store-abildtoft-kramme-cc-workflow
SKILL.md
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
CustomStoreorFeatureStoreto modern NgRx ComponentStore - You see patterns like
addApiAction().withReducer()oraddSocketAction().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
readonlyproperties - ALWAYS extract
initialStateto a constant; use eager initialization in the constructor - ALWAYS end class names with a
Storesuffix - 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
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
tapResponsefrom@ngrx/operators(not@ngrx/component-store) for effect error handling - 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
setStateorpatchState) - ALWAYS use
setprefix 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:
// 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/tapResponsein selectors
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
tapResponsenested in inner pipes (afterswitchMap/mergeMap) - ALWAYS use the RxJS
pipeoperator directly in effects:this.effect<Type>(pipe(...))instead ofthis.effect<Type>((trigger$) => trigger$.pipe(...)) - ALWAYS use
switchMapfor 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
- NOTE: The effect creates its own trigger observable internally; use
- ALWAYS import
tapResponsefrom@ngrx/operators, not@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
ConnectSharedDataAccessWebsocketServicein 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
filterandmap - NEVER use
takeUntilDestroyedfor root-provided stores- 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
inject()function instead of constructor injection - ALWAYS place all
inject()calls first in the class as readonly fields - ALWAYS use ECMAScript
#privateFieldsyntax for private members - NEVER use the
publicorprivatekeywords in TypeScript
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
Storeinjection 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
takeUntilDestroyedfor 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
- ALWAYS read state through selectors; use
- NEVER create manual subscriptions
- ALWAYS wire observables directly to updaters/effects; let ComponentStore manage subscriptions
- NEVER import
tapResponsefrom@ngrx/component-store- ALWAYS import from
@ngrx/operators:import { tapResponse } from '@ngrx/operators';
- ALWAYS import from
- NEVER provide empty observables to effects
- EXAMPLE: Use
this.loadEvents()notthis.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,connect,shared - NOTE: Application:
cms,shared,ufa(User-Facing Application) - NOTE: Type:
data-access,feature,ui, etc.
- 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
{ provide: Service, useValue: mockService }to mock dependencies - ALWAYS use
jest.spyOn()to verify side effects - CAN use
patchStatewith// eslint-disable-next-line no-restricted-syntaxfor 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:
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
anytype; useunknownwhen type is uncertain - ALWAYS use ECMAScript
#privateFieldsyntax for encapsulation - NEVER use the
publicorprivatekeywords in TypeScript class members
Angular Components Using Stores
- ALWAYS set
changeDetection: ChangeDetectionStrategy.OnPushin@Componentdecorator - ALWAYS use separate HTML files (do NOT use inline templates)
- ALWAYS place all
inject()calls first in the class as readonly fields - ALWAYS place
@Inputand@Outputproperties second in the class
Templates
- ALWAYS use native control flow (
@if,@for,@switch) instead of*ngIf,*ngFor,*ngSwitch - ALWAYS use the
*ngrxLetdirective orngrxPushpipe to handle Observables- ALWAYS prefer the
ngrxPushpipe overasyncfor one-off async bindings in templates - PREFER not using
*ngrxLetorngrxPushmultiple times for the same Observable; instead assign it to a template variable using@let
- ALWAYS prefer the
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.providersfor component-level stores
Before Submitting Code Review
- ALWAYS ensure all affected tests pass locally
- ALWAYS run formatting:
yarn run format(fromConnect/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.
Didn't find tool you were looking for?