kramme-connect-workflow/skills/kramme:connect:migrate-store-ngrx/SKILL.md
Use this Skill when working in the Connect monorepo and needing to migrate legacy CustomStore or FeatureStore implementations to NgRx ComponentStore.
npx skillsauth add abildtoft/kramme-cc-workflow kramme:connect:migrate-store-ngrxInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
When to use this skill:
Connect/ng-app-monolith/)CustomStore or FeatureStore to modern NgRx ComponentStoreaddApiAction().withReducer() or addSocketAction().withReducer()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.
Read the guideline keyword glossary from references/guideline-keywords.md.
ComponentStore<StateInterface>providedIn: 'root' for stores that need application-wide singleton behaviorreadonly propertiesinitialState to a constant; use eager initialization in the constructorStore suffix.store.tsEXAMPLE - 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);
}
}
addApiAction().withReducer() patterns with ComponentStore updaters and effectsaddSocketAction().withReducer() with updaters that accept observablestapResponse from @ngrx/operators (not @ngrx/component-store) for effect error handlingEXAMPLE - Replace API Actions with Effects:
// Legacy: addApiAction().withReducer()
// New: ComponentStore effect
readonly loadEvents = this.effect<void>(
pipe(
// switchMap: only the latest load matters
switchMap(() =>
this.#api.getEvents().pipe(
tapResponse({
next: (events) => this.setEvents(events),
error: (error) => this.#errorHandler.handle(error),
})
)
)
)
);
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)
)
);
}
parseDates: true with parseObject in the API ClientLegacy CustomStore actions configured with parseDates: true rely on a framework-level interceptor to convert ISO date strings to Date objects. When migrating to ComponentStore, move this responsibility to the API client.
parseDates: true by adding a parseJsonResponse helper to the API client file.pipe(map(response => parseJsonResponse(response))) on each HTTP methodEXAMPLE - Before (Legacy):
export const loadEvents = eventStore
.addApiAction('Event', 'Load')
.configure({ parseDates: true })
.withEffect(s => s.loadEvents)
.withReducer();
EXAMPLE - After (API Client):
import { coToDateOrNull } from '@consensus/co/util-date-times';
import { parseObject } from '@lib/helpers';
import { Observable, map } from 'rxjs';
/**
* Convert ISO date-time string properties to Date.
*/
function parseJsonResponse<T>(response: T): T {
return parseObject(response, (value: unknown) =>
typeof value === 'string' ? coToDateOrNull(value) : null
);
}
@Injectable({ providedIn: 'root' })
export class EventClient {
readonly #http = inject(HttpClient);
loadEvents(): Observable<Event[]> {
return this.#http
.get<Event[]>(`${this.#server}/api/events`)
.pipe(map(response => parseJsonResponse(response)));
}
}
showErrors: true with CoSnackServiceLegacy CustomStore actions configured with showErrors: true display error snackbars automatically. In ComponentStore effects, handle this explicitly in the tapResponse error callback.
CoSnackService and ErrorHandler in the storegetErrorMessage(error) from @consensus/co/util-http-errors to extract a user-friendly messagethis.#errorHandler.handleError(error) (for logging) and this.#snackService.error(...) (for user notification) in error handlersEXAMPLE - Before (Legacy):
export const saveEvent = eventStore
.addApiAction('Event', 'Save')
.configure({ showErrors: true })
.withEffect(s => s.saveEvent)
.withReducer();
EXAMPLE - After (ComponentStore):
import { ErrorHandler, inject } from '@angular/core';
import { CoSnackService } from '@consensus/co/ui-snackbars';
import { getErrorMessage } from '@consensus/co/util-http-errors';
// In the store class:
readonly #errorHandler = inject(ErrorHandler);
readonly #snackService = inject(CoSnackService);
readonly saveEvent = this.effect<SaveEventModel>(
pipe(
mergeMap(payload =>
this.#client.saveEvent(payload).pipe(
tapResponse({
next: data => this.#updateEvent(data),
error: (error: unknown) => {
this.#errorHandler.handleError(error);
this.#snackService.error(getErrorMessage(error));
},
})
)
)
)
);
setState or patchState)set prefix for updaters that replace entire state slicesupdateListItem from @lib/redux with Array.map using a ternary expressionPayloadType | Observable<PayloadType> - wire observables directlyEXAMPLE - Replace updateListItem:
// Legacy: updateListItem(state.events, x => x.id === data.id, data)
// New: Array.map with ternary
readonly #updateEvent = this.updater<Event>(
(state, data): EventStoreState => ({
...state,
events: state.events.map(x => (x.id === data.id ? data : x)),
})
);
EXAMPLE - Common updater patterns:
// Replace entire collection
readonly #setEvents = this.updater<Event[]>(
(state, events): EventStoreState => ({
...state,
events,
})
);
// Add item to collection
readonly #addEvent = this.updater<Event>(
(state, event): EventStoreState => ({
...state,
events: [...state.events, event],
})
);
// Update item in collection (replaces updateListItem)
readonly #updateEvent = this.updater<Event>(
(state, updated): EventStoreState => ({
...state,
events: state.events.map(e => (e.id === updated.id ? updated : e)),
})
);
// Remove item from collection
readonly #removeEvent = this.updater<string>(
(state, eventId): EventStoreState => ({
...state,
events: state.events.filter(e => e.id !== eventId),
})
);
// Replace boolean flag
readonly #setLoading = this.updater<boolean>(
(state, isLoading): EventStoreState => ({
...state,
isLoading,
})
);
$selectComponentStore.get() — always read via selectorswithLatestFrom(...)tap/tapResponse in selectorsEXAMPLE:
// 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)
);
tapResponse nested in inner pipes (after the flattening operator)pipe operator directly in effects: this.effect<Type>(pipe(...)) instead of this.effect<Type>((trigger$) => trigger$.pipe(...))mergeMap as the flattening operator in effects// switchMap: only the latest load matters when a new trigger should cancel the previous request// concatMap: serialize deletes so each rollback snapshot is consistent when ordering mattersthis.effectName(of(undefined))) when calling effects without arguments
this.effectName() insteadtapResponse from @ngrx/operators, not @ngrx/component-storeEXAMPLE - Correct import:
import { tapResponse } from '@ngrx/operators';
EXAMPLE - Default: mergeMap (no comment needed):
readonly saveEvent = this.effect<SaveEventModel>(
pipe(
mergeMap(payload =>
this.#client.saveEvent(payload).pipe(
tapResponse({
next: data => this.#updateEvent(data),
error: (error: unknown) => {
this.#errorHandler.handleError(error);
this.#snackService.error(getErrorMessage(error));
},
})
)
)
)
);
EXAMPLE - switchMap with justification comment:
readonly loadEvents = this.effect<void>(
pipe(
// switchMap: only the latest load matters
switchMap(() =>
this.#client.loadEvents().pipe(
tapResponse({
next: data => this.#setEvents(data),
error: (error: unknown) => {
this.#errorHandler.handleError(error);
this.#snackService.error(getErrorMessage(error));
},
})
)
)
)
);
EXAMPLE - Optimistic delete with concatMap and justification comment:
readonly deleteAnswer = this.effect<string>(
pipe(
withLatestFrom(this.answers$),
tap(([answerId]) => this.#removeAnswer(answerId)),
// concatMap: serialize deletes so each rollback snapshot is consistent
concatMap(([answerId, answersBefore]) =>
this.#client.deleteAnswer(answerId).pipe(
tapResponse({
next: () => {
// Already removed optimistically
},
error: (error: unknown) => {
this.#setAnswers(answersBefore);
this.#errorHandler.handleError(error);
this.#snackService.error(getErrorMessage(error));
},
})
)
)
)
);
ConnectSharedDataAccessWebsocketService in the store, not in a separate servicefilter and maptakeUntilDestroyed for root-provided 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)
)
);
}
inject() function instead of constructor injectioninject() calls first in the class as readonly fields#privateField syntax for private memberspublic or private keywords in TypeScriptEXAMPLE - 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);
Three patterns emerge for how components interact with ComponentStore effects:
Call the effect method directly. The store handles state updates internally. Use for loads, updates, completion actions, and deletes.
// Load data
this.#eventStore.loadEvents();
// Update an entity
this.#eventStore.completeInterview({ ...formValue, id: interviewId, result });
// Delete (store handles optimistic removal + rollback internally)
this.#eventStore.deleteAnswer(answerId);
// Immediately update component-local state
this.answer = null;
Call the effect, then use firstValueFrom with a selector to detect the new entity in state. Use when the consumer needs the created entity's ID (e.g., to navigate or update local state).
// Snapshot existing IDs before the create
const existingIds = new Set(
(await firstValueFrom(this.interviews$)).map(i => i.id)
);
// Fire the effect
this.#certStore.createInterview({
participantUserId: userId,
questionnaireId,
});
// Wait for the store to receive the created entity
const interview = await firstValueFrom(
this.interviews$.pipe(
map(interviews => interviews.find(i => !existingIds.has(i.id))),
filter(Boolean)
)
);
// Now use the entity (e.g., navigate)
this.#router.navigate(['interviews', interview.id], {
relativeTo: this.#route,
});
Call the effect, then immediately update component-local state. The store handles its own state via updaters; the component mirrors the change locally to keep its bindings in sync without waiting for a round-trip.
// Fire the effect
this.#certStore.updateAnswer({ ...this.answer, ...data });
// Optimistically update the component-local reference
this.answer = { ...this.answer, ...data };
provide-event-store.ts)Store injection from components/services only using this storeOnStoreInit, OnStateInit)provideComponentStore; prefer standard providerstakeUntilDestroyed for root-provided stores
ComponentStore.get()
withLatestFrom() in effects for one-off readstapResponse from @ngrx/component-store
@ngrx/operators: import { tapResponse } from '@ngrx/operators';this.loadEvents() not this.loadEvents(of(undefined))libs/<product>/<application>/<domain>/<type>-<name>
academy, coaching, connect, sharedcms, shared, ufa (User-Facing Application)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
{ provide: Service, useValue: mockService } to mock dependenciesjest.spyOn() to verify side effectspatchState with // eslint-disable-next-line no-restricted-syntax for test setup onlydescribe() blocks: describe(MyStore.name, () => ...)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();
});
});
inject())readonly prop$ = this.select(...))readonly effectName = this.effect(...))readonly setX = this.updater(...))any type; use unknown when type is uncertain#privateField syntax for encapsulationpublic or private keywords in TypeScript class memberschangeDetection: ChangeDetectionStrategy.OnPush in @Component decoratorinject() calls first in the class as readonly fields@Input and @Output properties second in the class@if, @for, @switch) instead of *ngIf, *ngFor, *ngSwitch*ngrxLet directive or ngrxPush pipe to handle Observables
ngrxPush pipe over async for one-off async bindings in templates*ngrxLet or ngrxPush multiple times for the same Observable; instead assign it to a template variable using @letinject() function instead of constructor injectioninject() calls first as private readonly fieldsprovidedIn: 'root' option for singleton services@Component.providers for component-level storesyarn run format (from Connect/ng-app-monolith)yarn exec nx affected --targets=lint,test --skip-nx-cacheSee Instructions Section for code examples.
development
Runs kramme:pr:code-review as a closeout review loop for local or PR branch changes before commit, ship, or final response. Use when the user asks for autoreview, second-model review, or a final code-review pass after non-trivial edits. Not for UX, visual, accessibility, or product review.
development
Guides topic-level understanding verification for a PR, branch, feature, document, spec, design decision, bug fix, or other concrete subject. Use when the user asks to confirm, quiz, drill, teach-and-check, or verify that they understand a topic. Maintains a topic-specific checklist artifact and requires demonstrated understanding before marking the topic complete. Not for ordinary explanations without verification, end-of-session summaries, or code/test correctness checks.
testing
Design a CI/CD pipeline with quality gates, a <10-minute budget, feature-flag lifecycle, and an exit checklist. Use when adding a new CI pipeline, changing gate configuration, or planning a rollout for a new service. Complementary to kramme:pr:fix-ci (which fixes failures in an existing pipeline). Covers gate ordering, secrets storage, branch protection, rollback mechanism, and staged-rollout guardrails — not a rollout-execution runbook.
tools
--- name: kramme:visual:demo-reel description: Capture local demo evidence for observable product behavior: screenshots, before/after image sets, browser reels, terminal recordings, and short GIF/video proof. Use when shipping UI changes, CLI features, or any change where PR reviewers would benefit from visual or behavioral evidence. argument-hint: "[what to capture] [--url <url>|auto] [--tier static|before-after|browser-reel|terminal-recording]" disable-model-invocation: true user-invocable: tr