Skip to content
Angular ng state 4 min read

Signal-Based State

Signals give Angular a fine-grained reactivity primitive that tracks reads and pushes updates only to the consumers that actually depend on a value. For state management this means you can build small, predictable state containers without RxJS subscriptions, manual change detection, or a heavyweight store library. This page shows how to assemble a lightweight, encapsulated state service from signal, computed, and plain methods — the pattern most teams reach for before they ever need NgRx.

Why signals for state

A signal is a wrapper around a value that knows who reads it. When the value changes, every computed and effect that read it re-runs automatically, and any template that interpolated it is marked for a localized re-render. Compared with a BehaviorSubject-based service, signals remove the subscription lifecycle, read synchronously (count() rather than count$ | async), and integrate with zoneless change detection.

ConcernBehaviorSubject serviceSignal service
Read current valuesubject.value / subscribesig() synchronously
Derived valuecombineLatest + mapcomputed(...)
Template bindingvalue$ | asyncvalue() directly
Cleanupmust unsubscribenone required
Zoneless supportpartialfirst-class

A lightweight state container

The recommended shape is a service that keeps its writable signals private and exposes read-only signals plus intent-named methods. This preserves encapsulation: components can read and call actions but can never mutate state directly.

import { Injectable, computed, signal } from '@angular/core';

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

@Injectable({ providedIn: 'root' })
export class TodoStore {
  // Private writable state
  private readonly _todos = signal<Todo[]>([]);
  private readonly _filter = signal<'all' | 'active' | 'done'>('all');

  // Public read-only views
  readonly todos = this._todos.asReadonly();
  readonly filter = this._filter.asReadonly();

  // Computed selectors derive from state and recompute lazily
  readonly visibleTodos = computed(() => {
    const all = this._todos();
    switch (this._filter()) {
      case 'active': return all.filter(t => !t.done);
      case 'done':   return all.filter(t => t.done);
      default:       return all;
    }
  });

  readonly remaining = computed(() =>
    this._todos().filter(t => !t.done).length,
  );

  // Methods express intent and own all mutations
  add(title: string): void {
    const id = Date.now();
    this._todos.update(list => [...list, { id, title, done: false }]);
  }

  toggle(id: number): void {
    this._todos.update(list =>
      list.map(t => (t.id === id ? { ...t, done: !t.done } : t)),
    );
  }

  remove(id: number): void {
    this._todos.update(list => list.filter(t => t.id !== id));
  }

  setFilter(filter: 'all' | 'active' | 'done'): void {
    this._filter.set(filter);
  }
}

set replaces the value, while update derives the next value from the current one — always return a new array or object so the signal detects the change by reference.

Gotcha: mutating an array in place (list.push(...)) does not notify readers, because the signal still holds the same reference. Treat signal state as immutable and produce fresh values with spread/map/filter.

Consuming the store in a component

Because reads are synchronous, templates use the signal call directly with the new control flow. No async pipe and no subscriptions to manage.

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TodoStore } from './todo.store';

@Component({
  selector: 'app-todos',
  standalone: true,
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [(ngModel)]="draft" (keyup.enter)="commit()" placeholder="New todo" />

    <p>{{ store.remaining() }} remaining</p>

    @for (todo of store.visibleTodos(); track todo.id) {
      <label>
        <input type="checkbox" [checked]="todo.done" (change)="store.toggle(todo.id)" />
        {{ todo.title }}
      </label>
    } @empty {
      <p>Nothing to show.</p>
    }
  `,
})
export class TodosComponent {
  protected readonly store = inject(TodoStore);
  protected draft = '';

  commit(): void {
    const title = this.draft.trim();
    if (title) {
      this.store.add(title);
      this.draft = '';
    }
  }
}

Output:

2 remaining
[ ] Buy milk
[x] Ship release
[ ] Write docs

Reacting to changes with effects

Use effect for side effects that should run whenever the state they read changes — persistence, logging, or syncing to a backend. Effects run in an injection context, so create them in a constructor or field initializer.

import { Injectable, effect, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class SettingsStore {
  private readonly _theme = signal(localStorage.getItem('theme') ?? 'light');
  readonly theme = this._theme.asReadonly();

  constructor() {
    // Persist automatically whenever the theme changes
    effect(() => localStorage.setItem('theme', this._theme()));
  }

  setTheme(theme: 'light' | 'dark'): void {
    this._theme.set(theme);
  }
}

Keep effects for genuine side effects only. Anything that produces a value other state depends on belongs in a computed, which is lazy, cached, and free of timing surprises.

Best practices

  • Keep writable signals private and expose asReadonly() views plus intent-named methods so mutations have a single, traceable source.
  • Prefer computed over effect for derived data — it is pure, memoized, and recomputes only when its dependencies actually change.
  • Treat signal values as immutable; build new arrays/objects in update rather than mutating in place.
  • Provide store services with providedIn: 'root' for app-wide state, or at a route/component level for state scoped to a feature.
  • Pair signal stores with ChangeDetectionStrategy.OnPush (or a zoneless app) to get the full fine-grained update benefit.
  • Avoid writing to a signal from inside a computed; derive in computed, mutate only in methods or effects.
Last updated June 14, 2026
Was this helpful?