Skip to content

Commit fa8f522

Browse files
feat: add create-signalstore skill (closes #107)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a679563 commit fa8f522

1 file changed

Lines changed: 308 additions & 0 deletions

File tree

  • .claude/skills/create-signalstore
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
---
2+
name: create-signalstore
3+
description: >
4+
Create a new NgRx SignalStore following the patterns used in this codebase.
5+
Use when asked to add a new store, state slice, or signal-based state
6+
container. Produces feature functions first, combines them in signalStore(),
7+
and adds a spec file.
8+
---
9+
10+
# Create a NgRx SignalStore
11+
12+
Follow these steps in order.
13+
14+
## Core patterns in this codebase
15+
16+
- **Features first, store second.** Break state into `withXxxFeature()` functions
17+
using `signalStoreFeature()`, then combine them in a final `signalStore()` call.
18+
- **`rxMethod`** (from `@ngrx/signals/rxjs-interop`) for any RxJS-based side
19+
effects (HTTP calls, router events, etc.). Never subscribe manually.
20+
- **`signalMethod`** (from `@ngrx/signals`) to react to signal changes inside
21+
a store or component. Prefer over `effect()`.
22+
- **`withLoadingFeature()`** from `@myorg/shared` whenever a feature performs
23+
async work (gives you `loading` and `error` state for free).
24+
- **`withEntities`** from `@ngrx/signals/entities` for entity collections.
25+
- **`withProps`** to inject services and expose them to downstream `withMethods`.
26+
- **`withHooks`** (often in its own `withXxxHooks()` feature) for `onInit`/
27+
`onDestroy` lifecycle logic.
28+
- **`withReducer` + `eventGroup`** from `@ngrx/signals/events` when you want
29+
Redux-style event dispatching (see counter store).
30+
- Export both the class and its instance type:
31+
`export type XxxStore = InstanceType<typeof XxxStore>;`
32+
33+
---
34+
35+
## Step 1 – Decide where the store lives
36+
37+
Stores belong in the `state/` folder of their library:
38+
39+
```
40+
libs/<feature>/src/lib/state/<feature>.store.ts
41+
libs/<feature>/src/lib/state/<feature>.store.spec.ts
42+
```
43+
44+
Export from the library's barrel if one exists (`index.ts` / `public-api.ts`).
45+
46+
---
47+
48+
## Step 2 – Write the store file
49+
50+
Use the template below and remove any sections that don't apply.
51+
52+
```typescript
53+
import { computed, inject } from '@angular/core';
54+
import { tapResponse } from '@ngrx/operators';
55+
import { patchState, signalStore, signalStoreFeature, withComputed, withHooks, withMethods, withProps, withState, type } from '@ngrx/signals';
56+
import { rxMethod } from '@ngrx/signals/rxjs-interop';
57+
import { pipe, switchMap, tap } from 'rxjs';
58+
// Only include if using entities:
59+
import { setAllEntities, withEntities } from '@ngrx/signals/entities';
60+
// Only include if using loading/error state:
61+
import { withLoadingFeature } from '@myorg/shared';
62+
63+
import { MyModel } from '../models/my-model';
64+
import { MyService } from '../services/my.service';
65+
66+
// ── State type & initial value ────────────────────────────────────────────────
67+
68+
export type MyState = {
69+
selectedId: string | null;
70+
};
71+
72+
export const myInitialState: MyState = {
73+
selectedId: null,
74+
};
75+
76+
// ── Feature function(s) ───────────────────────────────────────────────────────
77+
78+
export function withMyFeature() {
79+
return signalStoreFeature(
80+
withLoadingFeature(), // remove if no async work
81+
withEntities<MyModel>(), // remove if not an entity list
82+
withState(myInitialState),
83+
withProps(() => ({
84+
myService: inject(MyService),
85+
})),
86+
withComputed(({ selectedId, entities }) => ({
87+
selectedItem: computed(() => entities().find((e) => e.id === selectedId())),
88+
})),
89+
withMethods(({ myService, ...store }) => ({
90+
selectItem(id: string) {
91+
patchState(store, { selectedId: id });
92+
},
93+
loadAll: rxMethod<void>(
94+
pipe(
95+
tap(() => patchState(store, { loading: true })),
96+
switchMap(() =>
97+
myService.getAll().pipe(
98+
tapResponse({
99+
next: (items) => {
100+
patchState(store, setAllEntities(items));
101+
patchState(store, { loading: false });
102+
},
103+
error: (error) => {
104+
patchState(store, { error, loading: false });
105+
},
106+
}),
107+
),
108+
),
109+
),
110+
),
111+
})),
112+
);
113+
}
114+
115+
// ── Hooks feature (onInit / onDestroy) ────────────────────────────────────────
116+
117+
export function withMyHooks() {
118+
return signalStoreFeature(
119+
{ methods: type<{ loadAll: ReturnType<typeof rxMethod<void>> }>() },
120+
withHooks({
121+
onInit({ loadAll }) {
122+
loadAll();
123+
},
124+
}),
125+
);
126+
}
127+
128+
// ── Final store ───────────────────────────────────────────────────────────────
129+
130+
export const MyStore = signalStore(
131+
// { providedIn: 'root' }, ← add if this should be a singleton
132+
withMyFeature(),
133+
withMyHooks(),
134+
);
135+
136+
export type MyStore = InstanceType<typeof MyStore>;
137+
```
138+
139+
### Notes
140+
141+
- **Singleton stores** (app-wide state like auth, layout) add
142+
`{ providedIn: 'root' }` as the first argument to `signalStore()`.
143+
- **Feature stores** (scoped to a route/component) are provided in the
144+
component's `providers` array and do _not_ use `providedIn: 'root'`.
145+
- Chain multiple `withComputed` / `withMethods` blocks when a later block
146+
needs to read values produced by an earlier one.
147+
- Use `withFeature(({ someSignal }) => anotherFeature(someSignal))` to pass
148+
runtime values from one feature into another (see weather-forecast store).
149+
150+
---
151+
152+
## Step 3 – Write the spec file
153+
154+
```typescript
155+
import { TestBed } from '@angular/core/testing';
156+
import { patchState } from '@ngrx/signals';
157+
import { unprotected } from '@ngrx/signals/testing';
158+
import { provideHttpClientTesting } from '@angular/common/http/testing';
159+
160+
import { myInitialState, MyStore } from './my.store';
161+
162+
describe('MyStore', () => {
163+
let store: MyStore;
164+
165+
beforeEach(() => {
166+
TestBed.configureTestingModule({
167+
providers: [
168+
MyStore,
169+
provideHttpClientTesting(),
170+
// add any other deps here
171+
],
172+
});
173+
store = TestBed.inject(MyStore);
174+
});
175+
176+
it('should create', () => {
177+
expect(store).toBeTruthy();
178+
});
179+
180+
it('should select an item', () => {
181+
patchState(unprotected(store), { selectedId: 'abc' });
182+
expect(store.selectedId()).toBe('abc');
183+
});
184+
185+
// Add more cases: loading state, error state, computed values, etc.
186+
});
187+
```
188+
189+
Key testing utilities:
190+
191+
- **`unprotected(store)`** — unwraps a protected store so you can call
192+
`patchState` on it directly in tests.
193+
- **`TestBed.flushEffects()`** — flush pending `signalMethod` / `rxMethod`
194+
effects after dispatching events.
195+
- **`injectDispatch(eventGroup)`** from `@ngrx/signals/events` — dispatch
196+
typed events in tests when using `withReducer`.
197+
198+
---
199+
200+
## Step 4 – Run tests
201+
202+
```bash
203+
nx test <project-name>
204+
```
205+
206+
All tests must pass before committing.
207+
208+
---
209+
210+
## Step 5 – Follow the GitHub workflow
211+
212+
```bash
213+
# 1. Create issue (as chrisjwalk)
214+
gh issue create --title "feat: add MyStore" --body "..."
215+
216+
# 2. Branch
217+
git checkout -b feat/my-store-<issue-number>
218+
219+
# 3. Commit
220+
git add -A
221+
git commit -m "feat: add MyStore (closes #<issue-number>)
222+
223+
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"
224+
225+
# 4. Push & PR (as chrisjwalk-bot)
226+
gh auth switch --user chrisjwalk-bot
227+
git push origin feat/my-store-<issue-number>
228+
gh pr create --title "feat: add MyStore" --body "Closes #<issue-number>"
229+
230+
# 5. Switch back
231+
gh auth switch --user chrisjwalk
232+
```
233+
234+
---
235+
236+
## Quick reference – common patterns
237+
238+
### Entity store with filter
239+
240+
```typescript
241+
export function withMyEntityFeature() {
242+
return signalStoreFeature(
243+
withLoadingFeature(),
244+
withEntities<MyModel>(),
245+
withState({ filter: '' }),
246+
withComputed(({ entities, filter }) => ({
247+
filtered: computed(() => entities().filter((e) => e.name.includes(filter()))),
248+
})),
249+
withMethods((store, svc = inject(MyService)) => ({
250+
setFilter(filter: string) {
251+
patchState(store, { filter });
252+
},
253+
load: rxMethod<void>(
254+
pipe(
255+
tap(() => patchState(store, { loading: true })),
256+
switchMap(() =>
257+
svc.getAll().pipe(
258+
tapResponse({
259+
next: (items) => patchState(store, setAllEntities(items), { loading: false }),
260+
error: (error) => patchState(store, { error, loading: false }),
261+
}),
262+
),
263+
),
264+
),
265+
),
266+
})),
267+
);
268+
}
269+
```
270+
271+
### Event-driven store (counter pattern)
272+
273+
```typescript
274+
import { eventGroup, on, withReducer } from '@ngrx/signals/events';
275+
import { type } from '@ngrx/signals';
276+
277+
export const myEvents = eventGroup({
278+
source: 'My Feature',
279+
events: {
280+
setName: type<string>(),
281+
reset: type<void>(),
282+
},
283+
});
284+
285+
export function withMyReducer() {
286+
return signalStoreFeature(
287+
{ state: type<MyState>() },
288+
withReducer(
289+
on(myEvents.setName, ({ payload }) => ({ name: payload })),
290+
on(myEvents.reset, () => myInitialState),
291+
),
292+
);
293+
}
294+
```
295+
296+
### Injecting a store into a component
297+
298+
```typescript
299+
@Component({
300+
providers: [MyStore], // ← scoped to this component tree
301+
})
302+
export class MyComponent {
303+
readonly store = inject(MyStore);
304+
}
305+
```
306+
307+
For a singleton store (`providedIn: 'root'`), just inject it — no need to add
308+
it to `providers`.

0 commit comments

Comments
 (0)