Skip to main content

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)),
);
note

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​

AspectAngular SignalReact useState
Creationsignal(0)useState(0)
Readingcount() (function call)count (the value itself)
Updatingset() / update()setCount(v) / setCount(fn)
Where it can be definedAlso outside components (services, modules)Only inside components/hooks
Re-renderingUpdates only the dependent template partsThe component function re-runs
Declaring dependenciesAutomatically 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​

Aspectcomputed()useMemo
Specifying dependenciesAutomatic trackingHand-written dependency array (omissions cause bugs)
Evaluation timingOn read (lazy)During render
GuaranteeDoes not recompute if dependencies are unchangedA performance hint, not a guarantee
Where to defineAnywhere (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 effect must be created in an injection context (constructor, field initializer, or inside runInInjectionContext). 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.
  • effect became 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​

AspectAngular effect()React useEffect
Specifying dependenciesAutomatic trackingHand-written dependency array
Execution timingWhen dependencies change (during change detection)After render (when the dependency array changes)
CleanupRegistered via the onCleanup argumentReturned function
First runRuns after creationRuns after mount
Main useSyncing Signals outward (DOM, storage, logging)General: data fetching, subscriptions, DOM work, etc.
Async dependenciesSignals read after an await are not trackedListed 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 inside computed.
  • model() is a writable Signal that supports two-way binding with [(value)].
  • output() returns a dedicated OutputEmitterRef rather than an RxJS Subject, and you fire it with emit().

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​

LegacySignal-based
@Input() name: stringname = input<string>()
@Input({ required: true }) id!: numberid = input.required<number>()
@Output() clicked = new EventEmitter<void>()clicked = output<void>()
@Input() + @Output() valueChange pairvalue = model<T>()
@ViewChild('x') elel = viewChild<ElementRef>('x')
@ViewChildren(C)items = viewChildren(C)
@ContentChild / @ContentChildrencontentChild() / contentChildren()
Detecting changes with ngOnChangescomputed / 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 async pipe is not needed either.
  • Without initialValue, the type is Signal<T | undefined>. For Observables that do not emit synchronously, specify initialValue.
  • 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​

SituationRecommendation
UI state and derived valuesSignal / computed
One-off HTTP fetchHttpClient Observable → toSignal
Time-based control such as debounce, switchMap, retryRxJS
Continuous streams such as WebSocketRxJS → connect to the UI with toSignal
Referencing from templatesSignal (simpler than the async pipe)
Form value changesvalueChanges (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.

ElementRole
StoreThe single state tree for the whole app
ActionAn event describing "what happened"
ReducerA pure function that returns a new state from the current state and an Action
SelectorA (memoized) function that extracts needed values from the Store
EffectsHandle 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​

AspectNgRxRedux (Redux Toolkit)
Basic modelAction → Reducer → StoreSame
Exposing stateObservable / Signal (selectSignal)useSelector
Side effects@ngrx/effects (RxJS)Thunk / Listener Middleware
DIStore injected through Angular DIProvider + hooks
TypesActions/Selectors are strongly typedCan be typed similarly with RTK
DevToolsSupports Redux DevToolsSupported
BoilerplateHeavy (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 / requirementRecommendation
Local state within a componentsignal()
Sharing across components (small to medium)Services + Signals
Structured per-feature state, organized methodsNgRx SignalStore
Large scale, strict event history, time-travel debuggingNgRx 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.​

AngularClosest in ReactNotes
input() / output()props / callback propsSame idea
Service + Signals (providedIn: 'root')Zustand / JotaiModule-scoped store
Service provided via a component's providersContext ProviderScope limited to the subtree
NgRx StoreRedux ToolkitStrict Action/Reducer flow
NgRx SignalStoreZustand (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.