Take an Angular app zoneless + signals
package.json nor in node_modules , and angular.json declares "polyfills": [] . Change detection is driven by signals, and by them alone. What follows describes how that's wired in this portfolio, and the four or five places where the absence of zone genuinely changes the way code is written.
What zone.js used to do, and what takes over
setTimeout , promises, DOM listeners) to notify Angular as soon as a callback finished. On every notification, a detection cycle restarted from the root and re-checked the entire tree, including components where nothing had moved.
markForCheck , an input() update.
setTimeout that rewrites an ordinary field no longer triggers any refresh. With zone.js, the global tick caught this kind of mutation without anyone thinking about it. Without it, every source of change has to go through a signal, otherwise the view stays frozen.
Activation, a single provider
app.config.ts . provideZonelessChangeDetection() replaces the old provideZoneChangeDetection , and the rest of the configuration follows that choice.
withComponentInputBinding() binds route parameters to components' input() : the :slug of the article page arrives directly in an input.required<string>() , without manually reading the snapshot. provideClientHydration(withEventReplay()) replays events that happened during hydration, which matters more without zone: nothing is silently absorbing in the background a click that arrived before the app became interactive.
The reactive backbone
I18nService is a thin facade over an NgRx SignalStore and exposes only three read-only signals: lang , content and loading , typed Signal<Lang> / Signal<Content> / Signal<boolean> . Consumers never depend on the store's internal shape.
peek() populates the content (instant first render, compatible with static prerendering), then an asynchronous getContent() revalidates it. A language change is protected by a last-wins policy: if a more recent language has been requested in the meantime, the older result is discarded.
withHooks of the store, through an effect that reacts to lang() : it persists the preference in localStorage and reflects the value on <html lang="…"> . Nobody calls this code, it re-runs when the signal changes.
content() , and every computed or template that reads it recomputes without a single manual subscription.
Derived state computes itself
input , everything else flows from it via computed :
article depends on slug() and content() ; body depends on article() and lang() . Navigating to another article, or switching languages, recomposes everything without any synchronization code. computed values are memoized: body only re-parses the Markdown if the slug or the language actually changed.
PlayerService , which drives the simulated player on the home page. Playback time ( time ) and the play/pause state ( playing ) are writable signals. The list of chapters derives from the language via this.i18n.content().chapters , the current chapter derives from the time, and the elapsed time within that chapter derives from both. The template displays currentChapter() and follows automatically, without ngOnChanges or a manually triggered recalculation.
input.required<number>() and their active state via input.required<boolean>() , two values that feed directly into computed values. The BSP demo bubbles its events up to the parent via output<void>() . For truly trivial local state, a service can be reduced to a single line: the nav bar's search is a simple public readonly query = signal('') , written by the nav bar, read by the articles grid.
ChangeDetectionStrategy.OnPush . In zoneless this is coherent end to end: a view is only checked when a signal it consumes asks for it.
An interval driven by a signal
PlayerService advances a playback clock with a setInterval , but the setInterval lives inside an effect governed by the playing signal.
playing switches to false , the effect re-runs, onCleanup runs first, and clearInterval stops the loop. The rate() read inside the tick changes the step without rebuilding anything.
onCleanup is the classic trap. The interval would survive the pause, run several times in parallel after multiple toggles, and leak in tests as well as during SSR prerendering, where the timer would never have a reason to stop. The set() on time remains the only channel through which the tick informs the view: without zone.js, Angular only wakes up on the signal write, never on the setInterval itself.
When RxJS needs to feed a signal
router.events with a filter on NavigationEnd and a takeUntilDestroyed() , then in the subscribe it calls a load() that ends with a this.tally.set(...) .
takeUntilDestroyed() unsubscribes on component destruction without a manual ngOnDestroy . The toSignal() API would build the same bridge in a declarative way, but this portfolio never needed it: here, the rare streams boil down to a set() inside the subscribe .
The rule that prevents drift
public loading = false out of habit. A homegrown ESLint rule, local/prefer-signal-primitives , keeps the discipline.
bigint , literal, or union of primitives) and flags an error if it isn't initialized with signal() , computed() , model() or input() . The message is explicit: Public primitive field '{{name}}' should be a signal . It's wired at error level for every src/app/**/*.ts file, specs excluded.
Testing when there's no more zone
fakeAsync or tick() : the project doesn't contain a single occurrence. Two patterns replace them, described in the zoneless guide .
await fixture.whenStable() after an interaction, before asserting on the DOM. About twenty component specs follow this pattern.
PlayerService 's clock, change detection must be driven by hand. You force Vitest's fake timers, flush the effect with ApplicationRef.tick() (which schedules the setInterval ), then advance time.
pause() plus appRef.tick() , advancing a full second no longer moves the time. onCleanup did indeed cut the interval. This is zoneless code tested the way it runs: the changes are explicit, you choose when they happen.
Zoneless doesn't make the app faster by magic. What it changes is traceability: every redraw traces back to a specific signal, and a lint rule prevents state from escaping outside that model.