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 valueReading 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.