State management with Angular Signals: using signal, computed and effect where they belong
State management in Angular apps has long raised the same question: should this value be a component field, a BehaviorSubject in a service, or move into a store library? Signals give a smaller and more predictable answer: a value container that records its dependents when it is read and notifies only those dependents when it changes.
In this post I walk through the three core pieces, signal, computed and effect, what each one is for, and where I draw the line with RxJS. The goal is not to say "make everything a signal" or "never touch them", but to decide where each piece of state should live.
Signal: a readable and writable value
A signal wraps a value, and you read it by calling it like a function. You write with set or update:
import { signal } from '@angular/core';
const count = signal(0);
count(); // 0
count.set(5);
count.update(n => n + 1);
In a template you write {{ count() }} the same way. Angular knows which signals a template reads and re-evaluates that view only when one of them changes. Before, you had to manage this by hand with the async pipe or ChangeDetectorRef.
One detail matters here: setting a signal to the same reference does not count as a change. So do not mutate objects and arrays in place. items().push(x) triggers nothing; items.update(list => [...list, x]) does. Immutability becomes a natural habit with signals.
Computed: calculate derived state, do not store it
The most common mistake I see is storing a derivable value in a separate field and trying to keep it in sync by hand. A cart total, a filtered list, a "can the form be submitted" flag: if it can be derived from other state, define it with computed:
readonly items = signal<CartItem[]>([]);
readonly taxRate = signal(0.2);
readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.quantity, 0));
readonly total = computed(() =>
this.subtotal() * (1 + this.taxRate()));
computed is lazy and memoized: it is not calculated until it is read, and it is not recalculated until its dependencies change. Dependencies are tracked automatically, so you never write a dependency list. This removes the "two fields drifted apart" class of bugs, because there is a single source of truth.
Do not cause side effects inside a computed. Starting an HTTP request or writing to another signal in there leads to surprises, since you do not control when the value is calculated. A computed should behave like a pure function.
Effect: for side effects, not for passing state around
An effect is a side effect that runs when signals change. It suits work that talks to the world outside Angular: writing to localStorage, sending an analytics event, updating a third-party charting library.
constructor() {
effect(() => {
localStorage.setItem('cart', JSON.stringify(this.items()));
});
}
The wrong use of effect is calling set on another signal when one signal changes. It makes the chain hard to read and creates a risk of loops. If you find yourself needing that, the second value should usually be a computed. My rule of thumb: if the effect's output leaves Angular, use an effect; if it stays inside application state, use a computed.
An effect also has to be created in an injection context; a component or service constructor is the natural place. Angular manages its lifetime, so you do not clean up by hand.
State in a service: expose it read-only
Keeping shared state in a small service is enough for most applications. What matters is keeping write access inside the service:
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly count = computed(() => this._items().length);
add(item: CartItem) {
this._items.update(list => [...list, item]);
}
remove(id: string) {
this._items.update(list => list.filter(i => i.id !== id));
}
}
Components read items and count, but can change the state only through named operations like add and remove. How the state can change is readable in one file. You get most of the discipline of a store library without an extra dependency. If the app grows and you need time travel, detailed dev tooling or a strict event model, that is the point to evaluate a library.
The boundary with RxJS: event streams and current values
Signals do not replace RxJS; they do a different job. A signal answers "what is the value right now?" and always has a value. An observable is for events over time: keystrokes in a search box, WebSocket messages, cancellable HTTP requests, anything that needs debounceTime or switchMap.
The practical split:
- State the UI displays: signal.
- Event streams over time and combination logic: RxJS.
- The bridge between them:
toSignalturns an observable into a signal,toObservableturns a signal into an observable.
For example, you can filter search input with debounceTime and hand the result to the template with toSignal. Timing stays in RxJS, and the template simply reads a signal. toSignal can yield undefined when there is no initial value; set that explicitly with the initialValue option.
Common mistakes
These are the recurring problems I run into:
- In-place mutation. Changing an array or object and expecting the signal to update. Always produce a new reference.
- Side effects in computed. Calculations should stay pure.
- Syncing state with effects. Make derivable values
computed. - Making everything a signal. Local variables read once and constants do not need to be signals.
- Reading in the wrong place. Reading a signal outside an effect or computed, for example in an event handler, is fine, but that read creates no dependency. Make sure you read where tracking is actually needed.
Migrate in small steps
Do not try to convert an existing app in one go. Write local state in new components with signals, move small services that hold a BehaviorSubject over one at a time, leave complex flows in RxJS, and connect them with toSignal. Tests keep passing at each step and the team gets used to the new model.
The real gain of Signals is readability before performance: you can tell where a value comes from, what it depends on and who can change it just by reading the code. If you keep state in signal, derived values in computed and outside-world side effects in effect, your Angular code becomes easier both to follow and to change.