Angular in Practice #4: State Management — Cleanup with SignalStore

10 min read

In #3, we put signal state directly inside ProductService and called add/update/delete from there, even layering on optimistic updates. With only two screens, that’s reasonable — and frankly, for a small app it’s the correct answer.

The trouble starts the moment pages multiply. List page, detail page, search/filter sidebar, a quick-edit modal — when they all want to show the same products data in different shapes, signals and methods quietly accumulate in a single Service class, and “who changes this value, when, and how?” starts to blur. In this post, we clean that up with @ngrx/signals’ SignalStore.

NgRx vs SignalStore — which fits this app? #

We touched on this in Advanced #5 NgRx Introduction, but let me restate it. Classic NgRx (@ngrx/store + @ngrx/effects) is a tool for apps where multiple features share the same domain data, lots of teammates collaborate, and time-travel debugging carries real value. Boilerplate comes with the explicit unidirectional flow of actions, reducers, selectors, and effects.

The product-management app we’re working on is well below that threshold. One domain (products), two or three screens, one to a few people touching it. SignalStore fits that case. It treats signals as first-class, doesn’t define separate actions/reducers, and gathers state, derived values, and methods in one place. Lighter than classic NgRx for a small app, but more organized than a plain Service — it sits in between.

Setting up @ngrx/signals #

No separate provider registration needed. Just put { providedIn: 'root' } on signalStore, and it slots into the DI tree on its own. Install the package and you’re done.

terminal
ng add @ngrx/signals@latest

If ng add is inconvenient, npm i @ngrx/signals is enough. The other NgRx packages (@ngrx/store, @ngrx/effects) are not required. SignalStore works on its own, separate from classic NgRx.

signalStore’s four-piece set — withState, withComputed, withMethods, withHooks #

Before getting into the code, let’s sketch one picture in your head. SignalStore is built as signalStore(...features), and the features are essentially these four:

  • withState(initial) — initial state. Every field inside automatically becomes a signal
  • withComputed(({ ...state }) => ({ ... })) — derived values. Same idea as the signal computed
  • withMethods((store, ...deps) => ({ ... })) — actions that change state. Can pull dependencies via inject()
  • withHooks({ onInit, onDestroy }) — lifecycle. Used for automatic synchronization, like persistence

Fields laid down by withState become signals on the spot. You read them as store.items(), not state.items, with a function call, and changes go through patchState(store, { items: ... }). You never call set directly.

Implementing the Product Store #

Let’s move the state and methods that were scattered across ProductService from #3. Start with the domain types and initial state.

src/app/products/product.model.ts
export interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  stock: number;
}

export interface ProductsState {
  list: Product[];
  selectedId: number | null;
  loading: boolean;
  error: string | null;
}

export const initialProductsState: ProductsState = {
  list: [],
  selectedId: null,
  loading: false,
  error: null,
};

Now the store body. Everything fits in one file.

src/app/products/products.store.ts
import { computed, inject } from '@angular/core';
import {
  signalStore,
  withState,
  withComputed,
  withMethods,
  patchState,
} from '@ngrx/signals';

import { ProductsApi } from './products.api';
import { Product, initialProductsState } from './product.model';

export const ProductsStore = signalStore(
  { providedIn: 'root' },

  withState(initialProductsState),

  withComputed(({ list, selectedId }) => ({
    count: computed(() => list().length),
    selected: computed(() =>
      list().find((p) => p.id === selectedId()) ?? null,
    ),
    outOfStock: computed(() => list().filter((p) => p.stock === 0)),
  })),

  withMethods((store, api = inject(ProductsApi)) => ({
    async load() {
      patchState(store, { loading: true, error: null });
      try {
        const list = await api.fetchAll();
        patchState(store, { list, loading: false });
      } catch (err: any) {
        patchState(store, { error: err.message ?? 'Load failed', loading: false });
      }
    },

    select(id: number | null) {
      patchState(store, { selectedId: id });
    },

    async add(input: Omit<Product, 'id'>) {
      const tempId = -Date.now();
      const optimistic: Product = { ...input, id: tempId };
      patchState(store, ({ list }) => ({ list: [...list, optimistic] }));

      try {
        const created = await api.create(input);
        patchState(store, ({ list }) => ({
          list: list.map((p) => (p.id === tempId ? created : p)),
        }));
      } catch (err: any) {
        patchState(store, ({ list }) => ({
          list: list.filter((p) => p.id !== tempId),
          error: err.message ?? 'Add failed',
        }));
      }
    },

    async update(id: number, patch: Partial<Product>) {
      const prev = store.list().find((p) => p.id === id);
      if (!prev) return;

      patchState(store, ({ list }) => ({
        list: list.map((p) => (p.id === id ? { ...p, ...patch } : p)),
      }));

      try {
        await api.update(id, patch);
      } catch (err: any) {
        patchState(store, ({ list }) => ({
          list: list.map((p) => (p.id === id ? prev : p)),
          error: err.message ?? 'Update failed',
        }));
      }
    },

    async remove(id: number) {
      const prev = store.list();
      patchState(store, { list: prev.filter((p) => p.id !== id) });

      try {
        await api.remove(id);
      } catch (err: any) {
        patchState(store, { list: prev, error: err.message ?? 'Delete failed' });
      }
    },
  })),
);

Read through the flow once — the state shape lives in withState, the derived values most commonly consumed by the screen gather in withComputed, and the actual actions live in withMethods. Three clearly separated places.

In the second argument of withMethods, we pull in the API client via inject(ProductsApi). The same inject pattern from classic NgRx’s Effects class carries over here. Inside methods you can read the current state with store.list() as well as update via patchState.

Note
patchState accepts both shapes — the object literal patchState(store, { loading: true }) for full overwrite, and the callback patchState(store, ({ list }) => ({ list: [...list, x] })) for deriving from the previous state. When making new values from previous arrays/objects, the callback form is safer.

Using it from a component #

The screen side gets much lighter. Pull the store via inject(ProductsStore) and call its signals directly in the template.

src/app/products/products-page.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { ProductsStore } from './products.store';

@Component({
  selector: 'app-products-page',
  standalone: true,
  template: `
    @if (store.loading()) {
      <p>Loading…</p>
    } @else if (store.error()) {
      <p class="error">{{ store.error() }}</p>
    } @else {
      <p>Total {{ store.count() }} ({{ store.outOfStock().length }} out of stock)</p>

      @for (p of store.list(); track p.id) {
        <article (click)="store.select(p.id)">
          <h3>{{ p.name }}</h3>
          <p>{{ p.price | number }} KRW , stock {{ p.stock }}</p>
          <button (click)="store.remove(p.id); $event.stopPropagation()">Delete</button>
        </article>
      }

      @if (store.selected(); as sel) {
        <aside>Selected: {{ sel.name }}</aside>
      }
    }
  `,
})
export class ProductsPageComponent implements OnInit {
  protected readonly store = inject(ProductsStore);

  ngOnInit() {
    this.store.load();
  }
}

In the template, store.list(), store.count(), store.selected() — call them just like signals. No async pipe, no Subscription cleanup. It naturally meshes with OnPush change detection, redrawing precisely the parts the signals point to.

Relocating the optimistic update #

The optimistic update logic that lived inside ProductService in #3 has moved into the store’s withMethods here. It seems minor, but it’s an important shift — as side-effect responsibility moves to the store, the component simply says “please do this”. The try/catch and temporary-id management that were scattered across components now gather in one place.

update/remove follow the same pattern — capture the previous state (prev) in a variable, apply the change optimistically, and if the server rejects, roll back to that variable. The component just writes store.update(id, patch). Whatever screen calls it gets the same guarantees.

Filter / Sort — clean with withComputed #

The flow of filtering the list by search term and sorting by price fits the signal environment perfectly. Add the UI’s search term to the store, and derive the filtered result via withComputed.

src/app/products/products.store.ts (adding filter)
withState({
  ...initialProductsState,
  query: '',
  sortBy: 'name' as 'name' | 'price',
}),

withComputed(({ list, query, sortBy }) => ({
  filtered: computed(() => {
    const q = query().trim().toLowerCase();
    const base = q
      ? list().filter((p) => p.name.toLowerCase().includes(q))
      : list();
    return [...base].sort((a, b) =>
      sortBy() === 'price' ? a.price - b.price : a.name.localeCompare(b.name),
    );
  }),
})),

withMethods((store) => ({
  setQuery(q: string) { patchState(store, { query: q }); },
  setSortBy(s: 'name' | 'price') { patchState(store, { sortBy: s }); },
  // ... existing methods ...
})),

In the screen, take the search input’s (input) event with store.setQuery($event.target.value), and in the template render store.filtered() instead of store.list(). Done.

search input binding
<input
  type="search"
  [value]="store.query()"
  (input)="store.setQuery($any($event.target).value)"
  placeholder="Search by product name"
/>

<select [value]="store.sortBy()" (change)="store.setSortBy($any($event.target).value)">
  <option value="name">By name</option>
  <option value="price">By price</option>
</select>

filtered recomputes only when one of list / query / sortBy changes. If selectedId changes elsewhere, filtered returns the cached value as-is. This is the biggest win of signal-based derived values.

Persistence — auto-saving to sessionStorage with withHooks #

If you want the selected product’s id to survive a refresh, listen for signal changes in withHooks’s onInit and sync to sessionStorage. You can use effect directly inside the store.

src/app/products/products.store.ts (adding persistence)
import { effect } from '@angular/core';
import { signalStore, withHooks } from '@ngrx/signals';

const SELECTED_KEY = 'products.selectedId';

export const ProductsStore = signalStore(
  { providedIn: 'root' },

  // ... withState, withComputed, withMethods ...

  withHooks({
    onInit(store) {
      // 1) Restore once at boot
      const saved = sessionStorage.getItem(SELECTED_KEY);
      if (saved) patchState(store, { selectedId: Number(saved) });

      // 2) Auto-sync on subsequent changes via effect
      effect(() => {
        const id = store.selectedId();
        if (id == null) sessionStorage.removeItem(SELECTED_KEY);
        else sessionStorage.setItem(SELECTED_KEY, String(id));
      });
    },
  }),
);

The withHooks callback runs in the store’s injection context, not the component’s. So effect registers naturally there, and as long as the store is alive — for the entire app lifetime if providedIn: 'root' — it auto-syncs. No unsubscribe or cleanup to write.

You can wrap query and sortBy the same way to make search state survive refreshes too. That said, mindlessly persisting every field makes debugging hard, so it’s cleaner to pick fields that carry the user’s intent (search term, selected id, sort key) and store only those.

DevTools — compatible with NgRx Store DevTools #

@ngrx/signals works with the Redux DevTools extension as well. Add a single withDevtools('products') line from @ngrx/signals/devtools to the store, and you can browse the store’s state changes in chronological order in the browser’s Redux DevTools panel.

DevTools integration
import { withDevtools } from '@ngrx/signals/devtools';

export const ProductsStore = signalStore(
  { providedIn: 'root' },
  withDevtools('products'),
  withState(initialProductsState),
  // ...
);

That said, it’s a different flavor from classic NgRx’s action-level time travel. Since SignalStore has no separate actions, “which method call caused which change” is recorded as the label. For small apps this is enough; once more detailed flow tracking is needed, migrating to classic NgRx is a natural progression.

Tip
The most common mistake when adopting SignalStore is directly mutating another store from inside a store. Calling another store’s method from a withMethods block is fine, but using patchState to change another store’s signals directly is not. Coupling between stores should go through method calls only — each store is the single owner of its own state.

Wrapping up #

In this post, we moved the state and behavior from #3’s ProductService into a SignalStore for cleanup.

  • For our app’s scale, SignalStore is more appropriate than classic NgRx. An organized store without boilerplate for small apps
  • The four-piece set of withState / withComputed / withMethods / withHooks puts state, derived values, actions, and lifecycle in one file
  • Components pull via inject(ProductsStore) and call signals directly. No async pipe needed
  • Optimistic update moves from service to store, lightening components
  • Search and sort use withComputed for automatic memoization. Recomputes only when dependent signals change
  • withHooks + effect for sessionStorage persistence. Cleanup is automatic
  • Compatible with Redux DevTools. The path to classic NgRx is open as the app grows

In the next post, “Angular in Practice #5: Charts and Data Tables,” we layer real visualization on top of the store we’ve polished. We’ll build a sales chart with ng2-charts, a data table with sorting and pagination via CDK Table, and use the store’s withComputed to derive chart data cleanly.

X