Notes I Kept While Moving a Project to Angular 21
Last month I decided to move a side project to Angular 21. My intention was mundane — bump the version, fix whatever broke, move on. Two weeks later I was working in a framework that shared a name with the one I knew but felt like a different tool entirely. This isn't a changelog. It's the collection of "wait, that's different" moments I wrote down as I hit them.
What I noticed the day I ripped out Zone.js
When I first shipped provideZonelessChangeDetection() to production, I expected a small bump on a performance graph. What I got was something else: the end of a question I used to ask constantly while debugging — "why did this component just re-render?" With Zone.js in the picture, that question could turn into a small investigation. A setTimeout somewhere, a third-party library's event listener somewhere else, all of it triggering change detection, and you can't tell what triggered what just by staring at a template.
Without zones, a change is no longer a mystery event — it's traceable. A signal changed, everything depending on it updated, that's the whole story. At first this clarity felt almost uncomfortable, because it comes with a responsibility I wasn't used to: your state actually has to live in a signal. You can't assign to a component property and hope change detection sorts it out eventually. But that responsibility turned out to be a gift — the code now explains itself.
resource() didn't just replace RxJS, it killed a whole category of state bugs
On a project detail page I was fetching data based on a route parameter. The old way looked like: a subject, a switchMap, takeUntilDestroyed, a separate loading signal, a separate error signal, and the constant chore of keeping them all in sync by hand. It always felt like hand-rolling a tiny state machine.
With resource(), the same job became:
project = resource({
params: () => ({ slug: this.slug() }),
loader: ({ params }) => this.contentful.getProjectBySlug(params.slug),
});
project.value(), project.isLoading(), project.error() — all three are just there, synchronous, always consistent with each other. When the route changes, params reruns, a new fetch fires, and the previous in-flight request gets discarded. Every time I hand-wrote this before, I'd leave a bug somewhere — usually a race condition where a stale response arrived late and clobbered newer state. resource() gets this right by default, and I've simply stopped hunting for that bug.
This isn't RxJS being thrown out — it's still on the table anywhere real composition is needed. But for "just wire an HTTP call to component state," I don't reach for it anymore, and that difference mattered more than I expected.
Changing the control flow syntax wasn't cosmetic — the template started behaving like TypeScript
When I switched to @if / @for, I expected a cosmetic win — a shorter way to write what *ngIf used to do. The real difference showed up in the editor. Write @if (user(); as u) and the IDE actually knows the type of u — none of the occasional ambiguity that *ngIf + as could produce. The mandatory track in @for felt like busywork at first, until I remembered how many times in this codebase I'd tracked by index and then watched the DOM misbehave the moment a list got reordered. The compiler refusing to let me forget track quietly closed a category of bug I'd shipped more than once.
What the build tooling change actually felt like: the wait disappearing
After moving to the Vite/esbuild-based dev server, the thing I noticed most wasn't really a "feature" — it was the silence after hitting save. There used to be a small pause there, one I'd gotten so used to I stopped noticing it. Now that gap is gone, and once you consider that saving a file happens dozens of times a day, what disappeared wasn't just accumulated seconds — it was accumulated attention loss. The flow doesn't break.
SSR/hydration: no longer "hopefully it holds," now it just holds
This project already used server-side rendering, and hydration mismatches used to feel like a game of chance — a silent warning in the console, sometimes there, sometimes not, sometimes only in production. With Angular 21's hydration improvements, that uncertainty shrank considerably. The most concrete win: the code that skips server-rendering my WebGL background (the gl-bg component) and hands it off to the client needs far fewer special-case checks now, because the framework understands "this can't hydrate, skip it" better than my own workarounds did.
The overall feeling: the framework stands behind you now, not in front of you
Working with older Angular, I sometimes felt like I was writing code against the framework's assumptions — outsmarting the change detection cycle, preventing unnecessary re-renders, adding defensive code to make sure an RxJS subscription wasn't leaking. In Angular 21, most of that defensive layer is gone. Signals, zoneless, and resource() together don't just mean "less code" — they mean "less code to worry about." That sounds like a small difference until you've maintained a codebase for months and realized the real source of fatigue was never line count. It was that background worry.
If there are three things I'd genuinely tell you to try: use resource() in a real route-level data-fetching scenario (a toy todo list won't show you the difference), turn on zoneless in an existing project and leave one @HostListener-driven piece of state as a plain property instead of a signal, then watch what breaks (an instructive kind of pain), and misuse @for + track on purpose once, just to see firsthand when the DOM "remembers" and when it forgets. All three read as unremarkable in the docs. They don't read that way once you've actually done them.
Originally published on ysndmr.com.
Top comments (0)