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.