State management — localize when possible, globalize when needed
SkillDev toolsUse when adding or changing any state in this Angular workspace — creating a signal store, deciding whether state belongs in a local or root store, or wiring an async load. Always applies when state is introduced, not only when refactoring existing stores.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the State management — localize when possible, globalize when needed skill
What this skill tells your AI
The instructions your AI receives, as published by fabiangosebrink/dog-rate-app in .claude/skills/state-management/SKILL.md and read by ahel’s review.
NgRx Signals (signalStore) is the state tool. The guiding principle:
Localize when possible, globalize when needed. State and derived values live in the smallest scope that needs them. Only promote to the root store what is genuinely shared across containers.
Store scopes
- Local store, per container. A routed/container component provides its own store
(
providers: [XStore]on the component) — neverprovidedIn: 'root'. It holds that container's own state and the derived values only that container needs. In this appMyDogsStore,DogDetailsStore,AddDogStoreandMainDogStoreare all local stores provided on their container component. - Root domain store.
DogsStore(dogs/domain,providedIn: 'root') is the single source of truth for the loaded dog collection (withEntities<Dog>()→entities/entityMap), theloadingflag, and theloadDogs/addDog/updateDog/removeDogmethods that maintain it. Every dog container reads from it. Local stores inject it directly in theirwithComputed/withMethodsfactory (dogsStore = inject(DogsStore)) and build on it. - Sharing wider than one feature. If another feature ever needed dog data, you would
promote only the read (the entity collection and its
load…method) to the widest scope that reads it, and keep the writes in the owning feature. In this app the dog collection already lives indogs/domainand is read by every dog container, soDogsStoreis that shared read.
The placement test
Before putting a property in the root DogsStore, ask: is it read or mutated by more than
one container?
- Only one container reads it → it belongs in that container's local store — even if
it is derived. Derived-ness does not force it to root; "needed in more than one place" does.
MyDogsStore.myDogs,DogDetailsStore.detailDogandMainDogStore.selectedDogare all derived fromDogsStoreyet consumed by a single container, so they stay local. - Shared across containers, or it mutates the shared collection → the root
DogsStore. The dog entities and the load/add/update/remove that maintain them are shared, so they live inDogsStore.
Reads and writes don't have to share a scope. The shared read is the entity collection in
DogsStore. A local store derives its own view from that collection (myDogs, detailDog)
without copying it, and writes flow back into the shared collection in one of the two ways below.
Updating the shared collection from a local container
A local store cannot patchState DogsStore. It changes the shared collection in one of two
ways, both used in this app:
- Call a
DogsStoremethod directly. A local store injectsDogsStoreand calls one of its entity methods after its own service call.AddDogStore.addDogWithPictureuploads the photo, posts the dog, then callsdogsStore.addDog(dog);DogDetailsStore.loadSingleDogIfNotLoadedfetches a single dog that is not in the collection yet and callsdogsStore.addDog(dog). - Go through an event. For a flow that more than one store must react to, dispatch a user
event; the root store handles it and emits an API event that any store can react to. Delete
works this way (see "Coordinating stores with events"): a container dispatches
dogUserEvents.deleteDog,DogsStore(viawithDogRemove) deletes on the server and emitsdogAPIEvents.deleteDogSuccess, and bothDogsStoreandMyDogsStorereact to that event.
Worked example — the dogs feature
- Root
DogsStore(dogs/domain,providedIn: 'root'):withEntities<Dog>()(→entities/entityMap), aloadingflag,withDogRemove(), andloadDogs/addDog/updateDog/removeDog.loadDogsruns fromwithHooks({ onInit }), so the collection is populated once and every dog container reads it. MyDogsStore(local, on the my-dogs container): holdsmyDogsIds: string[], derivesmyDogsfromdogsStore.entityMap(), loads the ids withloadMyDogs, and reacts todogAPIEvents.deleteDogSuccessviawithReducerto drop a deleted id. Only the my-dogs page needsmyDogs, so it stays local.DogDetailsStore(local, on the detail container): holdsdogId: string | null, derivesdetailDogfromdogsStore.entityMap(), andloadSingleDogIfNotLoadedfetches the dog only when it is not already in the collection and adds it withdogsStore.addDog. The container passes the route id in; the store keeps the plain value, never a signal.
If a derived value like detailDog were ever needed by another container too, then promote it
to DogsStore.
Coordinating stores with events
Cross-store flows use @ngrx/signals/events, not one store reaching into another. Events come in
two groups: user events (an intent from the UI) and API events (the result of the work).
export const dogUserEvents = eventGroup({
source: 'Dogs User',
events: { deleteDog: type<Dog>() },
});
export const dogAPIEvents = eventGroup({
source: 'Dogs API',
events: { deleteDogSuccess: type<Dog>() },
});
- A container dispatches the user event:
inject(Dispatcher).dispatch(dogUserEvents.deleteDog(dog)). - The owning store handles it and emits the API event.
DogsStorecomposeswithDogRemove(), which useswithEventHandlersto calldogsApiService.deleteDog, navigate, and returndogAPIEvents.deleteDogSuccess(dog). - Any store reacts to the API event with
withReducer(on(...)):DogsStoreremoves the entity, andMyDogsStoredrops the id frommyDogsIds.
Reach for an event when a single user action must update state in more than one store. For a plain
write that only touches the shared collection, call the DogsStore method directly instead.
Async loads and request status
Async flows are rxMethod + tapResponse (from @ngrx/operators), and every mutation goes
through patchState. For "is this request in flight?" keep a plain loading flag in the store's
withState({ loading: false }). Set it in a leading tap before the call and clear it when the
data arrives, so every store models load state the same simple way:
loadDogs: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true })),
exhaustMap(() =>
dogsApiService.getDogs().pipe(
tapResponse({
next: (dogs) => {
patchState(store, setAllEntities(dogs), { loading: false });
notificationService.showSuccess('Dogs Loaded');
},
error: () => notificationService.showError(),
}),
),
),
),
),
Bind store.loading() in the template for spinners/indicators. Reference: DogsStore. Why: one
shared loading shape means loading UX — and the tests for it — look the same in every store
instead of each one inventing its own flag.
Non-negotiable rules
- The NgRx Signal Store is the only state management tool in this workspace. Never
introduce another state library, and never build a stateful service around a
BehaviorSubject/Subjectto share state between components — use a store instead, even for state that feels too small to justify one. - Mutate store state only via
patchState(or the NgRx entity updatersaddEntity/upsertEntity/removeEntity/setAllEntities). Never assign to or mutate state (or a held object) directly. - Never store a
Signalin store state. AsignalStore'swithStateproperties are already signals — hold the plain value (dogId: string | null), not aSignal<T>. A container may pass a route input signal into anrxMethod(that is whatrxMethodis for — seeDogDetailsStore.loadSingleDogIfNotLoaded), but the state the store keeps is the resolved plain value. - Never hold shared state, or state that must outlive a single render, in a plain component
signal()field. If it's shared or long-lived, it belongs in a store, scoped per the placement test above. - Components hold no logic. They bind store signals and forward events; the decisions (what is selected, when to fetch, add versus update, navigation, derivations) live in a store.
If a request seems to need one of the prohibited options above, prefer a Signal Store solution instead and briefly explain the choice.
See also the angular-components skill (clean component conventions) and the
create-signal-store skill (mechanics of building a single store).
Signals
- GitHub stars
- 99
- Forks
- 11
- Last commit
- Jul 2026
Advanced
- Catalog kind
- skill
- Gateway key
state-management-fabiangosebrink- Source
- github.com/fabiangosebrink/dog-rate-app