Signals and State Management
Angular Signals are conceptually similar to React's useState and represent a new reactivity model introduced in Angular 16+.
1. Signal Basics
A Signal is "a wrapper that holds a value and can notify about changes". When it is read from a template or from computed / effect, Angular tracks where the value is used and, when it changes, updates only the places that need it.
Creating Signals with signal()
import { signal } from '@angular/core';
const count = signal(0); // WritableSignal<number>
const user = signal<User | null>(null);
Reading and Updating Values (set, update)
A Signal is a function. To read the value, call it like count().
count(); // read -> 0
count.set(5); // replace the value directly
count.update((n) => n + 1); // compute a new value from the current one
Update objects and arrays immutably.
const todos = signal<Todo[]>([]);
todos.update((list) => [...list, { id: 1, title: 'Shopping', done: false }]);
todos.update((list) =>
list.map((t) => (t.id === 1 ? { ...t, done: true } : t)),
);
mutate() existed in the Angular 16 developer preview, but it was removed in Angular 17. Mutating arrays or objects in place does not work well with change detection or computed memoization. Today you use only set() and update(), always producing a new reference.
A Signal does not notify when you set an equal value (equal by Object.is). If you need a custom equality check, pass the equal option.
const point = signal({ x: 0, y: 0 }, {
equal: (a, b) => a.x === b.x && a.y === b.y,
});
Using Signals in a Component
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0);
increment(): void {
this.count.update((n) => n + 1);
}
}
If you do not want to expose writes, expose a read-only Signal with asReadonly().
private readonly _count = signal(0);
readonly count = this._count.asReadonly();
Comparison with React useState
| Aspect | Angular Signal | React useState |
|---|---|---|
| Creation | signal(0) | useState(0) |
| Reading | count() (function call) | count (the value itself) |
| Updating | set() / update() | setCount(v) / setCount(fn) |
| Where it can be defined | Also outside components (services, modules) | Only inside components/hooks |
| Re-rendering | Updates only the dependent template parts | The component function re-runs |
| Declaring dependencies | Automatically tracked (no array) | Hand-written dependency array |
In React the function re-runs on every render, whereas an Angular component is created once and Signal changes propagate to the relevant parts of the template. Because of this, Signals are not bound by the Rules of Hooks and can be used anywhere, such as in services.
2. Computed Signals
Derived State with computed()
computed() is a read-only Signal derived from other Signals.
import { signal, computed } from '@angular/core';
@Component({
selector: 'app-todo-summary',
template: `
<p>{{ remaining() }} / {{ todos().length }} remaining</p>
@for (t of visibleTodos(); track t.id) {
<li>{{ t.title }}</li>
}
`,
})
export class TodoSummaryComponent {
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active'>('all');
remaining = computed(() => this.todos().filter((t) => !t.done).length);
visibleTodos = computed(() =>
this.filter() === 'all'
? this.todos()
: this.todos().filter((t) => !t.done),
);
}
computed has the following characteristics.
- Lazy evaluation: it is not computed until it is read.
- Memoization: it reuses the previous result as long as the Signals it depends on have not changed.
- Read-only: you cannot call
set().
Automatic Dependency Tracking
The Signals actually called inside a computed function are automatically recorded as dependencies. With conditional branches, only the Signals in the branch that was taken become dependencies.
const showDetail = signal(false);
const detail = signal('Detail');
const label = computed(() => (showDetail() ? detail() : 'Hidden'));
// While showDetail is false, changes to detail() do not trigger recomputation
Comparison with React useMemo
| Aspect | computed() | useMemo |
|---|---|---|
| Specifying dependencies | Automatic tracking | Hand-written dependency array (omissions cause bugs) |
| Evaluation timing | On read (lazy) | During render |
| Guarantee | Does not recompute if dependencies are unchanged | A performance hint, not a guarantee |
| Where to define | Anywhere (class fields, etc.) | Inside components/hooks |
The frequent "forgot a dependency" mistake with React's useMemo / useCallback structurally cannot happen with Signals.
linkedSignal (Supplement)
linkedSignal() in Angular 19+ is a "writable derived Signal" that resets in response to other Signals but can also be overwritten by user actions. For example, it can reset the selection to the first item when the list of options changes.
const options = signal(['A', 'B', 'C']);
const selected = linkedSignal(() => options()[0]);
selected.set('B'); // the user picks one
options.set(['X', 'Y']); // when the source changes, selected resets to 'X'
3. Effects
Running Side Effects with effect()
effect() is a side effect that runs every time the Signals it reads change. Use it for reflecting state to the world outside Signals: logging, saving to localStorage, integrating third-party libraries, and so on.
import { Component, effect, signal } from '@angular/core';
@Component({ selector: 'app-theme', template: `...` })
export class ThemeComponent {
theme = signal<'light' | 'dark'>('light');
constructor() {
// Create it in an injection context (constructor or field initializer)
effect(() => {
document.body.dataset['theme'] = this.theme();
localStorage.setItem('theme', this.theme());
});
}
}
- An
effectmust be created in an injection context (constructor, field initializer, or insiderunInInjectionContext). It is destroyed automatically when the component is destroyed. - The first run happens during change detection after creation; after that it is scheduled asynchronously whenever a dependent Signal changes.
effectbecame stable in Angular 20.
Cleanup
The effect function receives an onCleanup callback, which lets you register teardown logic that is called before the next run or on destruction.
effect((onCleanup) => {
const id = this.userId();
const timer = setInterval(() => console.log('polling', id), 5000);
onCleanup(() => clearInterval(timer));
});
Notes on Writing to Signals
Updating another Signal inside an effect can cause infinite loops and hard-to-follow data flow. Up to Angular 18 this was forbidden by default and required the allowSignalWrites option; from Angular 19 onward, writes are allowed. Still, the principle is to express derived values with computed / linkedSignal rather than syncing them in an effect.
// Bad: syncing derived state in an effect
effect(() => this.fullName.set(`${this.first()} ${this.last()}`));
// Good: express it with computed
fullName = computed(() => `${this.first()} ${this.last()}`);
Comparison with React useEffect and Differences
| Aspect | Angular effect() | React useEffect |
|---|---|---|
| Specifying dependencies | Automatic tracking | Hand-written dependency array |
| Execution timing | When dependencies change (during change detection) | After render (when the dependency array changes) |
| Cleanup | Registered via the onCleanup argument | Returned function |
| First run | Runs after creation | Runs after mount |
| Main use | Syncing Signals outward (DOM, storage, logging) | General: data fetching, subscriptions, DOM work, etc. |
| Async dependencies | Signals read after an await are not tracked | Listed explicitly in the array |
An important difference is that useEffect tends to be used as an all-purpose tool that runs after rendering, whereas Angular's effect is meant for limited use cases. For data fetching, prefer HttpClient + toSignal or resource; for derived values, prefer computed.
4. Signal-based Components
Component inputs, outputs, and queries have also been replaced by Signal-based APIs. They were introduced in Angular 17.1+ and became stable in 19.
input() / output() / model()
import { Component, input, output, model, computed, numberAttribute } from '@angular/core';
@Component({
selector: 'app-rating',
template: `
<button (click)="rate(value() + 1)">+</button>
<span>{{ label() }}: {{ value() }}</span>
`,
})
export class RatingComponent {
label = input('Rating'); // optional (with default)
max = input.required<number>(); // required
step = input(1, { transform: numberAttribute }); // value transformation
value = model(0); // two-way binding
changed = output<number>(); // event
protected remaining = computed(() => this.max() - this.value());
rate(v: number): void {
this.value.set(v);
this.changed.emit(v);
}
}
<app-rating [max]="5" [(value)]="score" (changed)="onChanged($event)" />
input()returns a read-only Signal. You can use it directly insidecomputed.model()is a writable Signal that supports two-way binding with[(value)].output()returns a dedicatedOutputEmitterRefrather than an RxJSSubject, and you fire it withemit().
viewChild / viewChildren / contentChild / contentChildren
import { Component, viewChild, viewChildren, ElementRef, effect } from '@angular/core';
@Component({
selector: 'app-form',
template: `
<input #nameInput />
@for (item of items; track item) {
<app-row [item]="item" />
}
`,
})
export class FormComponent {
items = ['a', 'b', 'c'];
nameInput = viewChild.required<ElementRef<HTMLInputElement>>('nameInput');
rows = viewChildren(RowComponent);
constructor() {
effect(() => console.log('rows:', this.rows().length));
}
focus(): void {
this.nameInput().nativeElement.focus();
}
}
viewChild() returns Signal<T | undefined>, and viewChild.required() returns a Signal<T> that throws an error if nothing is found. Unlike @ViewChild, the result is a Signal, so you can handle it naturally in computed / effect without waiting for ngAfterViewInit. contentChild / contentChildren work the same way, retrieving children projected through <ng-content>.
Migrating from @Input / @Output Decorators
| Legacy | Signal-based |
|---|---|
@Input() name: string | name = input<string>() |
@Input({ required: true }) id!: number | id = input.required<number>() |
@Output() clicked = new EventEmitter<void>() | clicked = output<void>() |
@Input() + @Output() valueChange pair | value = model<T>() |
@ViewChild('x') el | el = viewChild<ElementRef>('x') |
@ViewChildren(C) | items = viewChildren(C) |
@ContentChild / @ContentChildren | contentChild() / contentChildren() |
Detecting changes with ngOnChanges | computed / effect |
Migration can be automated with Angular CLI schematics.
ng generate @angular/core:signal-input-migration
ng generate @angular/core:output-migration
ng generate @angular/core:signal-queries-migration
In React terms, input() corresponds to props, output() to callback props (onChanged), and model() to a "value + onChange" pair.
5. Interoperability with RxJS
Signals are well suited to handling "the current value", while RxJS is well suited to handling "a stream of events over time". The two can be converted via @angular/core/rxjs-interop.
toSignal(): Observable → Signal
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-user-list',
template: `
@for (u of users(); track u.id) {
<li>{{ u.name }}</li>
} @empty {
<li>No users</li>
}
`,
})
export class UserListComponent {
private http = inject(HttpClient);
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
}
- Subscription is automatic and is unsubscribed automatically when the component (injection context) is destroyed. The
asyncpipe is not needed either. - Without
initialValue, the type isSignal<T | undefined>. For Observables that do not emit synchronously, specifyinitialValue. - If the Observable errors, that error is thrown when the Signal is read.
toObservable(): Signal → Observable
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs';
query = signal('');
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap((q) => this.http.get<Item[]>('/api/search', { params: { q } })),
),
{ initialValue: [] },
);
toObservable uses an effect to emit values, so the latest value is emitted at change-detection time, not synchronously. Multiple updates may be coalesced so that only the last value arrives.
Guidelines for When to Use Which
| Situation | Recommendation |
|---|---|
| UI state and derived values | Signal / computed |
| One-off HTTP fetch | HttpClient Observable → toSignal |
Time-based control such as debounce, switchMap, retry | RxJS |
| Continuous streams such as WebSocket | RxJS → connect to the UI with toSignal |
| Referencing from templates | Signal (simpler than the async pipe) |
| Form value changes | valueChanges (Observable) → toSignal if needed |
The principle is: state in Signals, composition of async flows in RxJS, converting at the boundary. Also note that APIs that handle async data fetching directly as Signals, such as resource() / httpResource() (Angular 19+, experimental), are being introduced.
6. Large-Scale State Management with NgRx
NgRx Store Overview
NgRx is a state management library that implements the Redux pattern with Angular + RxJS.
| Element | Role |
|---|---|
| Store | The single state tree for the whole app |
| Action | An event describing "what happened" |
| Reducer | A pure function that returns a new state from the current state and an Action |
| Selector | A (memoized) function that extracts needed values from the Store |
| Effects | Handle side effects such as HTTP calls (RxJS-based) |
// todos.actions.ts
import { createActionGroup, props, emptyProps } from '@ngrx/store';
export const TodoActions = createActionGroup({
source: 'Todos',
events: {
'Add': props<{ title: string }>(),
'Toggle': props<{ id: number }>(),
'Clear Completed': emptyProps(),
},
});
// todos.reducer.ts
import { createFeature, createReducer, on } from '@ngrx/store';
interface State { todos: Todo[]; }
const initialState: State = { todos: [] };
export const todosFeature = createFeature({
name: 'todos',
reducer: createReducer(
initialState,
on(TodoActions.add, (state, { title }) => ({
...state,
todos: [...state.todos, { id: Date.now(), title, done: false }],
})),
on(TodoActions.toggle, (state, { id }) => ({
...state,
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
})),
),
});
// Usage in a component
private store = inject(Store);
todos = this.store.selectSignal(todosFeature.selectTodos); // get as a Signal
add(title: string) { this.store.dispatch(TodoActions.add({ title })); }
// app.config.ts
provideStore({ [todosFeature.name]: todosFeature.reducer }),
provideEffects(),
Similarities and Differences with Redux
| Aspect | NgRx | Redux (Redux Toolkit) |
|---|---|---|
| Basic model | Action → Reducer → Store | Same |
| Exposing state | Observable / Signal (selectSignal) | useSelector |
| Side effects | @ngrx/effects (RxJS) | Thunk / Listener Middleware |
| DI | Store injected through Angular DI | Provider + hooks |
| Types | Actions/Selectors are strongly typed | Can be typed similarly with RTK |
| DevTools | Supports Redux DevTools | Supported |
| Boilerplate | Heavy (Action/Reducer/Selector/Effects) | Lighter with RTK createSlice |
NgRx SignalStore (the New Signal-based Store)
SignalStore in @ngrx/signals is a lightweight state management built on Signals. You can declare state, derived values, and methods in one place without writing Actions or Reducers.
import { computed } from '@angular/core';
import {
signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';
interface TodoState {
todos: Todo[];
filter: 'all' | 'active';
}
export const TodoStore = signalStore(
{ providedIn: 'root' },
withState<TodoState>({ todos: [], filter: 'all' }),
withComputed(({ todos, filter }) => ({
visible: computed(() =>
filter() === 'all' ? todos() : todos().filter((t) => !t.done),
),
remaining: computed(() => todos().filter((t) => !t.done).length),
})),
withMethods((store) => ({
add(title: string): void {
patchState(store, (s) => ({
todos: [...s.todos, { id: Date.now(), title, done: false }],
}));
},
setFilter(filter: TodoState['filter']): void {
patchState(store, { filter });
},
})),
);
@Component({
selector: 'app-todos',
template: `
<p>{{ store.remaining() }} remaining</p>
@for (t of store.visible(); track t.id) { <li>{{ t.title }}</li> }
`,
})
export class TodosComponent {
readonly store = inject(TodoStore);
}
Alternative for Small Apps (Services + Signals)
Many apps do not need NgRx; simply holding Signals in a service is enough.
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
add(item: CartItem): void {
this._items.update((list) => [...list, item]);
}
clear(): void {
this._items.set([]);
}
}
Rule of Thumb for Choosing
| Scale / requirement | Recommendation |
|---|---|
| Local state within a component | signal() |
| Sharing across components (small to medium) | Services + Signals |
| Structured per-feature state, organized methods | NgRx SignalStore |
| Large scale, strict event history, time-travel debugging | NgRx Store |
7. State Sharing Patterns Between Components
Parent-Child: Input/Output
Make the data flow explicit: input() from parent to child, and output() (or model()) from child to parent.
// Parent
@Component({
selector: 'app-parent',
imports: [ChildComponent],
template: `
<app-child [items]="items()" (selected)="onSelected($event)" />
`,
})
export class ParentComponent {
items = signal<Item[]>([]);
onSelected(item: Item): void { /* ... */ }
}
Siblings: Shared Services
To share state without going through a common parent, put Signals in a service provided with providedIn: 'root' (or in a parent component's providers). Like the CartService above, each component inject()s it and reads or writes.
@Component({ selector: 'app-cart-badge', template: `Cart: {{ cart.items().length }}` })
export class CartBadgeComponent {
readonly cart = inject(CartService);
}
@Component({ selector: 'app-product', template: `<button (click)="buy()">Add</button>` })
export class ProductComponent {
private readonly cart = inject(CartService);
buy(): void { this.cart.add({ id: 1, price: 100, qty: 1 }); }
}
The service's scope is controlled either by providedIn: 'root' (app-wide) or by a component's providers (only that component and its descendants, with a separate instance).
Global: Store Pattern
When you want to share state across the whole app and manage the flow of changes centrally, use NgRx Store / SignalStore. Global state such as the logged-in user, permissions, and theme settings is typical.
Comparison with React Context, Redux, Zustand, etc.
| Angular | Closest in React | Notes |
|---|---|---|
input() / output() | props / callback props | Same idea |
Service + Signals (providedIn: 'root') | Zustand / Jotai | Module-scoped store |
Service provided via a component's providers | Context Provider | Scope limited to the subtree |
| NgRx Store | Redux Toolkit | Strict Action/Reducer flow |
| NgRx SignalStore | Zustand (structured version) | State, derived values, and methods in one place |
In React you have to decide things like "Context tends to widen the re-render scope, so use an external store for frequently changing state". In Angular, because Signals update only fine-grained parts of the template, services + Signals give you sufficiently fine-grained sharing, and the DI hierarchy lets you control scope flexibly.