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
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.