State management — localize when possible, globalize when needed

SkillDev tools

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

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) — never providedIn: 'root'. It holds that container's own state and the derived values only that container needs. In this app MyDogsStore, DogDetailsStore, AddDogStore and MainDogStore are 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), the loading flag, and the loadDogs / addDog / updateDog / removeDog methods that maintain it. Every dog container reads from it. Local stores inject it directly in their withComputed / withMethods factory (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 in dogs/domain and is read by every dog container, so DogsStore is 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.detailDog and MainDogStore.selectedDog are all derived from DogsStore yet 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 in DogsStore.

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 DogsStore method directly. A local store injects DogsStore and calls one of its entity methods after its own service call. AddDogStore.addDogWithPicture uploads the photo, posts the dog, then calls dogsStore.addDog(dog); DogDetailsStore.loadSingleDogIfNotLoaded fetches a single dog that is not in the collection yet and calls dogsStore.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 (via withDogRemove) deletes on the server and emits dogAPIEvents.deleteDogSuccess, and both DogsStore and MyDogsStore react to that event.

Worked example — the dogs feature

  • Root DogsStore (dogs/domain, providedIn: 'root'): withEntities<Dog>() (→ entities / entityMap), a loading flag, withDogRemove(), and loadDogs/addDog/updateDog/removeDog. loadDogs runs from withHooks({ onInit }), so the collection is populated once and every dog container reads it.
  • MyDogsStore (local, on the my-dogs container): holds myDogsIds: string[], derives myDogs from dogsStore.entityMap(), loads the ids with loadMyDogs, and reacts to dogAPIEvents.deleteDogSuccess via withReducer to drop a deleted id. Only the my-dogs page needs myDogs, so it stays local.
  • DogDetailsStore (local, on the detail container): holds dogId: string | null, derives detailDog from dogsStore.entityMap(), and loadSingleDogIfNotLoaded fetches the dog only when it is not already in the collection and adds it with dogsStore.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. DogsStore composes withDogRemove(), which uses withEventHandlers to call dogsApiService.deleteDog, navigate, and return dogAPIEvents.deleteDogSuccess(dog).
  • Any store reacts to the API event with withReducer(on(...)): DogsStore removes the entity, and MyDogsStore drops the id from myDogsIds.

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/Subject to 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 updaters addEntity/upsertEntity/removeEntity/setAllEntities). Never assign to or mutate state (or a held object) directly.
  • Never store a Signal in store state. A signalStore's withState properties are already signals — hold the plain value (dogId: string | null), not a Signal<T>. A container may pass a route input signal into an rxMethod (that is what rxMethod is for — see DogDetailsStore.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