Angular – Signals

July 3, 20263 min readUpdated 8/21/2026

A signal is a value that knows when it has changed, and knows who is reading it. That second half is the whole idea — everything else follows from it.

private readonly _items = signal<CartItem[]>([]);
private readonly _orderType = signal<OrderType>('DELIVERY');

Three operations:

this._items();                          // read
this._items.set([]);                    // replace
this._items.update((items) => [...items, line]);   // derive from the current value

Reading is a function call, and that is not cosmetic. Calling the signal is how Angular knows you read it. Read one inside a template, a computed or an effect, and that reader is now subscribed — automatically, with no dependency array to keep in step and no way to get the list wrong.

Why this replaced Zone.js

Before signals, Angular could not know what had changed. Zone.js patched every async API in the browser — setTimeout, every event listener, every XHR — so that after anything happened, Angular could re-check the entire component tree and diff the results. It worked, and it meant the framework did work proportional to the size of your app rather than to the size of the change.

Signals invert that. When a signal changes it knows exactly which templates read it, so only those are re-rendered. The pizza app has no zone.js installed at all — it runs zoneless, which is only possible because its state is signals.

Equality, and the mutation trap

A signal notifies when its value changes by Object.is. For a primitive that is what you expect. For an object or an array it means reference equality:

// Does NOT notify. Same array, same reference.
this._items().push(line);

// Notifies. New array.
this._items.update((items) => [...items, line]);

This is the single most common signal bug. The rule is the one React taught: treat state as immutable, replace rather than mutate. update() exists to make that comfortable, and it also gives you the "derive from the current value" guarantee without reading the signal first.

asReadonly()

The cart keeps its writable signals private and exposes read-only views:

readonly items = this._items.asReadonly();
readonly orderType = this._orderType.asReadonly();
readonly hydrated = this._hydrated.asReadonly();

Components can read cart.items() and cannot call cart.items.set(…) — it is not on the type. Every change goes through a method on the service, which is where the rules about what a valid cart looks like actually live. The underscore-prefixed private plus a public read-only view is the convention worth copying.

Signals in a service, not a provider component

@Injectable({ providedIn: 'root' })
export class CartService {

There is no <CartProvider> wrapping the app. providedIn: 'root' makes the service a singleton, and any component that injects it gets the same instance — so shared state needs no provider component and no position in the tree.

That difference is structural rather than cosmetic. React's four nested providers in main.tsx have an order that matters whenever one consumes another; Angular's four services do not, because a dependency is looked up by token rather than by walking up the tree.

Signals are not observables

Both are "reactive" and they model different things.

A signal always has a value. You can read it at any moment, synchronously, and get an answer. It has no concept of completing, erroring, or of a sequence.

An observable is a stream of events over time. It may emit nothing, or many things, or fail; you cannot ask it for its current value; and it needs subscribing to and unsubscribing from.

State is a value — the cart, the current user, the menu. Events are a stream — a debounced search, a websocket. The pizza app uses signals for the first and RxJS for the second, and converts at the boundary with toSignal. Two lessons from here, that boundary gets its own worked example.

What is next

Signals you do not set: computed for values derived from other signals, and effect for the things that must happen when they change.