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.
| Concern | BehaviorSubject service | Signal service |
|---|---|---|
| Read current value | subject.value / subscribe | sig() synchronously |
| Derived value | combineLatest + map | computed(...) |
| Template binding | value$ | async | value() directly |
| Cleanup | must unsubscribe | none required |
| Zoneless support | partial | first-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
privateand exposeasReadonly()views plus intent-named methods so mutations have a single, traceable source. - Prefer
computedovereffectfor derived data — it is pure, memoized, and recomputes only when its dependencies actually change. - Treat signal values as immutable; build new arrays/objects in
updaterather 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 incomputed, mutate only in methods or effects.