Angular course ยท Module 10: State Management

SignalStore (NGRX 17+)

1 min read ยท

NgRx SignalStore is a modern, signal-based library for state management.

Installation and Basics

npm install @ngrx/signals
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';

interface SamuraiState {
  samurai: Samurai[];
  loading: boolean;
  filter: string;
}

const initialState: SamuraiState = {
  samurai: [],
  loading: false,
  filter: ''
};

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

  // State
  withState(initialState),

  // Computed signals
  withComputed((store) => ({
    filteredSamurai: computed(() => {
      const filter = store.filter().toLowerCase();
      return store.samurai().filter(s =>
        s.name.toLowerCase().includes(filter)
      );
    }),
    count: computed(() => store.samurai().length)
  })),

  // Methods (actions)
  withMethods((store, http = inject(HttpClient)) => ({
    loadSamurai(): void {
      patchState(store, { loading: true });

      http.get<Samurai[]>('/api/samurai').subscribe({
        next: (samurai) => patchState(store, { samurai, loading: false }),
        error: () => patchState(store, { loading: false })
      });
    },

    setFilter(filter: string): void {
      patchState(store, { filter });
    },

    addSamurai(samurai: Samurai): void {
      patchState(store, (state) => ({
        samurai: [...state.samurai, samurai]
      }));
    }
  }))
);

// Usage in a component
@Component({
  template: `
    <input (input)="store.setFilter($any($event.target).value)">
    @for (s of store.filteredSamurai(); track s.id) {
      <div>{{ s.name }}</div>
    }
  `
})
export class SamuraiListComponent {
  store = inject(SamuraiStore);

  constructor() {
    this.store.loadSamurai();
  }
}
Code for this lesson: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="ngrx-guide">
      <h1>NgRx SignalStore - Guide</h1>

      <section>
        <h2>What is NgRx SignalStore?</h2>
        <p>NgRx SignalStore is a modern state management library that uses Angular Signals. It is simpler than the classic NgRx Store.</p>

        <div class="comparison">
          <div class="box classic">
            <h3>Classic NgRx</h3>
            <ul>
              <li>Actions</li>
              <li>Reducers</li>
              <li>Effects</li>
              <li>Selectors</li>
              <li>Lots of boilerplate</li>
            </ul>
          </div>

          <div class="box signal">
            <h3>NgRx SignalStore</h3>
            <ul>
              <li>withState()</li>
              <li>withComputed()</li>
              <li>withMethods()</li>
              <li>Less code</li>
              <li>Signals!</li>
            </ul>
          </div>
        </div>
      </section>

      <section>
        <h2>Creating a SignalStore</h2>
        <pre><code>{{ storeCode }}</code></pre>
      </section>

      <section>
        <h2>Using in a component</h2>
        <pre><code>{{ componentCode }}</code></pre>
      </section>

      <section>
        <h2>Asynchronous effects</h2>
        <pre><code>{{ effectsCode }}</code></pre>
      </section>

      <section>
        <h2>Configuration</h2>
        <pre><code>{{ configCode }}</code></pre>
      </section>

      <div class="tips">
        <h2>Tips</h2>
        <ul>
          <li><strong>When to use SignalStore?</strong> When state is shared between many components.</li>
          <li><strong>When are Signals enough?</strong> For local component state.</li>
          <li><strong>Immutability:</strong> Always create new objects instead of mutating existing ones.</li>
          <li><strong>Selectors:</strong> Use withComputed() for derived data.</li>
        </ul>
      </div>
    </div>
  `,
  styles: [`
    .ngrx-guide {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #64ffda;
    }

    h2 {
      color: #64ffda;
      border-bottom: 2px solid #64ffda;
      padding-bottom: 10px;
    }

    section {
      background: rgba(0,0,0,0.3);
      border-radius: 10px;
      padding: 20px;
      margin: 20px 0;
    }

    pre {
      background: #0d1421;
      padding: 15px;
      border-radius: 8px;
      overflow-x: auto;
      border-left: 4px solid #64ffda;
    }

    code {
      color: #e6e6e6;
      font-family: 'Fira Code', monospace;
      font-size: 0.85em;
      line-height: 1.5;
    }

    .comparison {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 20px;
      margin: 20px 0;
    }

    @media (max-width: 600px) {
      .comparison {
        grid-template-columns: 1fr;
      }
    }

    .box {
      padding: 15px;
      border-radius: 10px;
    }

    .box.classic {
      background: rgba(255, 107, 107, 0.2);
      border: 2px solid #ff6b6b;
    }

    .box.signal {
      background: rgba(100, 255, 218, 0.2);
      border: 2px solid #64ffda;
    }

    .box h3 {
      margin-top: 0;
    }

    .box ul {
      margin: 0;
      padding-left: 20px;
    }

    .box li {
      margin: 5px 0;
    }

    .tips {
      background: rgba(100, 255, 218, 0.1);
      border: 2px solid #64ffda;
      border-radius: 10px;
      padding: 20px;
    }

    .tips ul {
      list-style: none;
      padding: 0;
    }

    .tips li {
      padding: 10px 0;
      border-bottom: 1px solid rgba(100, 255, 218, 0.2);
    }

    .tips li:last-child {
      border-bottom: none;
    }
  `]
})
export class AppComponent {
  storeCode = `// samurai.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed } from '@angular/core';

interface SamuraiState {
  samuraiList: Samurai[];
  selectedId: number | null;
  loading: boolean;
}

const initialState: SamuraiState = {
  samuraiList: [],
  selectedId: null,
  loading: false
};

export const SamuraiStore = signalStore(
  // Initial state
  withState(initialState),

  // Selectors (computed)
  withComputed((store) => ({
    selectedSamurai: computed(() => {
      const id = store.selectedId();
      return store.samuraiList().find(s => s.id === id) ?? null;
    }),
    totalCount: computed(() => store.samuraiList().length),
    totalGold: computed(() =>
      store.samuraiList().reduce((sum, s) => sum + s.gold, 0)
    )
  })),

  // Methods (actions)
  withMethods((store) => ({
    selectSamurai(id: number): void {
      patchState(store, { selectedId: id });
    },

    addSamurai(samurai: Samurai): void {
      patchState(store, {
        samuraiList: [...store.samuraiList(), samurai]
      });
    },

    removeSamurai(id: number): void {
      patchState(store, {
        samuraiList: store.samuraiList().filter(s => s.id !== id),
        selectedId: store.selectedId() === id ? null : store.selectedId()
      });
    }
  }))
);`;

  componentCode = `// samurai-list.component.ts
import { Component, inject } from '@angular/core';
import { SamuraiStore } from './samurai.store';

@Component({
  selector: 'app-samurai-list',
  standalone: true,
  providers: [SamuraiStore], // Or in app.config.ts for global state
  template: \`
    <div>
      <p>Number of samurai: {{ store.totalCount() }}</p>
      <p>Total gold: {{ store.totalGold() }}</p>

      @for (samurai of store.samuraiList(); track samurai.id) {
        <div (click)="store.selectSamurai(samurai.id)">
          {{ samurai.name }} - {{ samurai.gold }} gold
        </div>
      }

      @if (store.selectedSamurai(); as selected) {
        <div class="detail">
          Selected: {{ selected.name }}
        </div>
      }
    </div>
  \`
})
export class SamuraiListComponent {
  store = inject(SamuraiStore);
}`;

  effectsCode = `// samurai.store.ts - with HTTP effects
import { signalStore, withState, withMethods } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { pipe, switchMap, tap } from 'rxjs';

export const SamuraiStore = signalStore(
  withState(initialState),

  withMethods((store, http = inject(HttpClient)) => ({
    // Method with RxJS
    loadSamurai: rxMethod<void>(
      pipe(
        tap(() => patchState(store, { loading: true })),
        switchMap(() => http.get<Samurai[]>('/api/samurai')),
        tap(samuraiList => patchState(store, {
          samuraiList,
          loading: false
        }))
      )
    ),

    // async/await method
    async loadSamuraiAsync(): Promise<void> {
      patchState(store, { loading: true });

      const response = await fetch('/api/samurai');
      const samuraiList = await response.json();

      patchState(store, { samuraiList, loading: false });
    }
  }))
);`;

  configCode = `// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { SamuraiStore } from './stores/samurai.store';

export const appConfig: ApplicationConfig = {
  providers: [
    // Global store - available throughout the app
    SamuraiStore,

    // Or with feature flags
    provideStore(),
    provideState({ name: 'samurai', reducer: samuraiReducer }),
    provideEffects(SamuraiEffects)
  ]
};`;
}

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the patchState() function used for in NgRx SignalStore?

  2. 2. What does the withState() function define in signalStore()?

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the signalStore call with its main blocks in their typical written order (one line, left to right):

  • Code editor

    Complete the SignalStore for managing samurai.

  • Horizontal ordering

    Arrange the command to install the NgRx SignalStore library (one line, left to right):

  • Click in order

    Click the elements in order to build a field that injects a SignalStore in a component (modern Angular):

Useful articles