Angular State Management with a Signal Store Pattern

Angular Tutorial: Beginner to Advanced · 16. Advanced forms, state and RxJS · 6 min read · Updated

As an app grows, components start to share state: the current user, a cart, filters, a list of items. Without a plan, that state ends up scattered. A store gives each piece of state one owner, one way to change it, and one way to read it.

What you will learn

  • A simple signal-based store with state, selectors and actions
  • How to handle async work and optimistic updates
  • When to choose a library such as NgRx Signals

The pattern

  1. State lives in private signals.
  2. Selectors are computed() values exposed read-only.
  3. Actions are methods that change state. Components never write the signals directly.

A todo store with async actions

// src/app/state/todo-api.ts
import { Injectable } from '@angular/core';

export interface Todo {
  id: number;
  title: string;
  done: boolean;
}

// A fake API so the example runs anywhere. Swap in HttpClient later.
@Injectable({ providedIn: 'root' })
export class TodoApi {
  private data: Todo[] = [
    { id: 1, title: 'Read the signals guide', done: true },
    { id: 2, title: 'Build a store', done: false },
  ];

  async list(): Promise<Todo[]> {
    await new Promise((r) => setTimeout(r, 200));
    return structuredClone(this.data);
  }

  async setDone(id: number, done: boolean): Promise<void> {
    await new Promise((r) => setTimeout(r, 200));
    if (id === 2 && done) {
      throw new Error('Server rejected this change');
    }
    this.data = this.data.map((t) => (t.id === id ? { ...t, done } : t));
  }
}
// src/app/state/todo-store.ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { Todo, TodoApi } from './todo-api';

interface TodoState {
  entities: Record<number, Todo>;
  ids: number[];
  loading: boolean;
  error: string | null;
  filter: 'all' | 'open' | 'done';
}

const initial: TodoState = { entities: {}, ids: [], loading: false, error: null, filter: 'all' };

@Injectable({ providedIn: 'root' })
export class TodoStore {
  private readonly api = inject(TodoApi);
  private readonly state = signal<TodoState>(initial);

  // Selectors
  readonly loading = computed(() => this.state().loading);
  readonly error = computed(() => this.state().error);
  readonly filter = computed(() => this.state().filter);
  readonly all = computed(() => this.state().ids.map((id) => this.state().entities[id]));
  readonly visible = computed(() => {
    const f = this.filter();
    return this.all().filter((t) => (f === 'all' ? true : f === 'done' ? t.done : !t.done));
  });
  readonly openCount = computed(() => this.all().filter((t) => !t.done).length);

  // Actions
  async load(): Promise<void> {
    this.patch({ loading: true, error: null });
    try {
      const list = await this.api.list();
      this.patch({
        loading: false,
        ids: list.map((t) => t.id),
        entities: Object.fromEntries(list.map((t) => [t.id, t])),
      });
    } catch {
      this.patch({ loading: false, error: 'Could not load todos.' });
    }
  }

  setFilter(filter: TodoState['filter']): void {
    this.patch({ filter });
  }

  // Optimistic update: change the UI first, undo if the server says no.
  async toggle(id: number): Promise<void> {
    const current = this.state().entities[id];
    if (!current) {
      return;
    }
    const next = !current.done;
    this.setTodo({ ...current, done: next });
    try {
      await this.api.setDone(id, next);
    } catch {
      this.setTodo(current); // roll back
      this.patch({ error: `Could not update "${current.title}".` });
    }
  }

  private setTodo(todo: Todo): void {
    this.state.update((s) => ({ ...s, entities: { ...s.entities, [todo.id]: todo } }));
  }

  private patch(change: Partial<TodoState>): void {
    this.state.update((s) => ({ ...s, ...change }));
  }
}
// src/app/state/todo-page.ts
import { Component, inject, OnInit } from '@angular/core';
import { TodoStore } from './todo-store';

@Component({
  selector: 'app-todo-page',
  template: `
    <h2>Todos ({{ store.openCount() }} open)</h2>

    <div role="group" aria-label="Filter">
      @for (f of filters; track f) {
        <button type="button" [attr.aria-pressed]="store.filter() === f" (click)="store.setFilter(f)">{{ f }}</button>
      }
    </div>

    @if (store.error(); as message) {
      <p role="alert">{{ message }}</p>
    }
    @if (store.loading()) {
      <p role="status">Loading...</p>
    }

    <ul>
      @for (todo of store.visible(); track todo.id) {
        <li>
          <label>
            <input type="checkbox" [checked]="todo.done" (change)="store.toggle(todo.id)" />
            {{ todo.title }}
          </label>
        </li>
      }
    </ul>
  `,
})
export class TodoPage implements OnInit {
  protected readonly store = inject(TodoStore);
  protected readonly filters = ['all', 'open', 'done'] as const;

  ngOnInit(): void {
    this.store.load();
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { TodoPage } from './state/todo-page';

@Component({
  imports: [TodoPage],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Signal store</h1>
  <app-todo-page />
</main>

Tick "Build a store" and watch it jump back with an error message. This fake server rejects it on purpose, so you can see the rollback.

Test the store

// src/app/state/todo-store.spec.ts
import { TestBed } from '@angular/core/testing';
import { Todo, TodoApi } from './todo-api';
import { TodoStore } from './todo-store';

describe('TodoStore', () => {
  const todos: Todo[] = [
    { id: 1, title: 'One', done: false },
    { id: 2, title: 'Two', done: true },
  ];

  function setup(setDone: (id: number, done: boolean) => Promise<void>) {
    TestBed.configureTestingModule({
      providers: [{ provide: TodoApi, useValue: { list: async () => todos, setDone } }],
    });
    return TestBed.inject(TodoStore);
  }

  it('loads todos and counts the open ones', async () => {
    const store = setup(async () => {});
    await store.load();
    expect(store.all().length).toBe(2);
    expect(store.openCount()).toBe(1);
  });

  it('filters the visible todos', async () => {
    const store = setup(async () => {});
    await store.load();
    store.setFilter('done');
    expect(store.visible().map((t) => t.id)).toEqual([2]);
  });

  it('keeps the optimistic update when the server accepts it', async () => {
    const store = setup(async () => {});
    await store.load();
    await store.toggle(1);
    expect(store.all().find((t) => t.id === 1)?.done).toBe(true);
  });

  it('rolls back and shows an error when the server rejects it', async () => {
    const store = setup(async () => {
      throw new Error('no');
    });
    await store.load();
    await store.toggle(1);
    expect(store.all().find((t) => t.id === 1)?.done).toBe(false);
    expect(store.error()).toContain('Could not update');
  });
});

Because the store depends on TodoApi, tests replace the API with a small fake. No network, fast and predictable.

When to use a library

The plain pattern above is enough for many apps. Consider NgRx Signals (@ngrx/signals) or similar when:

  • many people work on the same code and want a standard structure,
  • you want ready-made entity helpers, devtools or reusable store features,
  • state logic is shared across many stores.

NgRx's classic Store with actions and reducers suits very large apps that need strict event tracking. Libraries solve scale and consistency, and they add concepts to learn. Do not add one before you feel the need.

The key parts

  • Keep one owner of each piece of state. Expose read-only selectors.
  • Immutable updates ({ ...s, ... }) keep change detection simple and predictable.
  • Store server data separately from UI state such as open dialogs and filters if they grow.
  • Optimistic updates feel fast, but you must be able to roll back.
  • Test the store without components.

Try it yourself

  1. Add an addTodo action with optimistic creation and a temporary id.
  2. Persist the filter in localStorage using an effect().
  3. Replace TodoApi with HttpClient calls and keep the tests passing.

Quick check

  1. Why do components not write store signals directly?
  2. What must an optimistic update always have?
  3. How do you test a store that calls an API?

Next, strengthen your Observable skills in advanced RxJS patterns. The signals guide lists related APIs.

Frequently asked questions

Should every piece of state go in a global store?

No. Keep state as local as possible: component signals first, then a feature store, and a global store only for truly app-wide data.

Is NgRx still relevant?

Yes for large teams and complex flows. The newer signal-based NgRx APIs are lighter than the classic Store, so check their current documentation.