Writing

Signals and the things that stopped breaking

October 16, 2026

SOURCE COMPUTED TEMPLATE items filter visible count @for (…) {{ count() }} title {{ title() }} not touched
Change filter and only what read it re-runs. The title binding never hears about it.

There was a component at work that tracked one value four different ways. An @Input() setter stashed it in a private field, ngOnChanges recomputed a filtered list from it, a BehaviorSubject re-broadcast it for a child, and the template read the result through an async pipe. Every piece made sense when it was written. Together they produced a bug where the list and the count next to it disagreed by one item, for one frame, only when the input changed twice in quick succession. Nobody had done anything wrong. Four things were holding the same truth, and they didn’t always update in the same order.

That’s the problem signals are built for, and the idea is small: a signal is a value that knows who’s reading it. Read it inside a computed() or a template and Angular remembers the dependency. Change it and only the things that actually read it get recalculated. You stop telling the framework what to update. It already knows.

The first win was almost embarrassing. That whole setter, ngOnChanges, and subject routine turned into an input() and a computed(). The filtered list became a function of the inputs instead of a copy of them, so there was nothing to keep in sync, because nothing was being synced. The off-by-one bug didn’t get fixed so much as it stopped being expressible. I enjoy those more than normal fixes, and I don’t get many of them.

Then came the part nobody puts on the conference slide. effect() is right there, it looks friendly, and it’s the easiest way to drag the old habits into the new world. Watch one signal, write to another, and congratulations: you’ve rebuilt ngOnChanges with nicer syntax and slightly worse debugging. The rule I ended up with is that if an effect writes a signal, that signal almost certainly wanted to be a computed(). Effects are for leaving the signal world (logging, touching the DOM, talking to something outside), not for moving data around inside it.

RxJS also didn’t go anywhere, and shouldn’t. Signals are good at “what is the value right now.” They are not good at “what happened over time”: a search box you want to debounce, an HTTP call you want to cancel when the next one starts, a websocket that’s a stream of events rather than a value. That’s still observable territory. What changed is where it ends. The stream does its stream things in a service, toSignal() turns the result into a value at the edge, and the component never has to know there was a pipe involved.

The migration itself was boring, which is the nicest thing I can say about a migration. Leaf components first, since they have the fewest opinions about everything around them. Everything was already OnPush, so the change detection story barely moved. Services got converted when a component needed them to, not as some grand rewrite weekend. At no point was the app half broken. It was just half old for a while.

Looking back, signals didn’t remove much complexity. What they did was make the shape of it visible. Every piece of state is now either a source, something that gets set, or derived, something that gets computed, and the code says which out loud. Most of the bugs I used to chase lived in the gap where derived state was pretending to be a source. Signals closed that gap. The four copies of one value are now one value and a couple of functions, and the count and the list have agreed with each other ever since.

— Adi

← Back to blog