Angular 18: A Closer Look at Change Detection and More

Angular 18 arrived in May 2024, bringing with it a significant milestone: an experimental path to running applications without Zone.js. Beyond this headline feature, the release includes thoughtful refinements to the router, forms, and server-side rendering workflows. Let's examine what's changed.

📂 Source Code

Running Without Zone.js

Zone.js has been a cornerstone of Angular's change detection since the beginning. The library's role is to detect when asynchronous operations complete, signaling that bound data might have changed. Its mechanism involves patching browser APIs like HTMLInputElement, Promise, and XmlHttpRequest—a technique known as monkey patching that leverages JavaScript's dynamic nature.

While generally effective, this approach has drawbacks. Debugging issues that stem from Zone.js's patching can be notoriously difficult. Moreover, since not every event handler alters bound data, change detection often runs more frequently than necessary. For developers building reusable web components that hide Angular's internals, consumers of those components are still tied to a specific Zone.js version.

Version 18 introduces an experimental alternative: a change detection mode that operates entirely without Zone.js. This experimental status allows the Angular team to gather real-world feedback before committing fully. Enabling it requires calling provideExperimentalZonelessChangeDetection during application bootstrap:

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    […]
  ]
}

Without Zone.js, Angular must rely on other signals to know when to run change detection. These triggers align with those used in the OnPush strategy:

  • An observable bound via the async pipe emits a new value.
  • A bound signal updates its value.
  • The reference of an input property changes.
  • A UI event fires that has a bound event handler (like click).
  • Change detection is triggered manually via the application's API.

For teams that have consistently adopted OnPush, the transition to Zone-less operation should be relatively straightforward. Those with codebases that don't fit this pattern can continue using Zone.js without issue—the Angular team plans to maintain support for it, acknowledging that not every application will make the switch.

For fresh projects, though, planning for a Zone-less future is wise once the mode leaves its experimental phase. The upcoming Signal Components will make this transition even smoother, as their inherent reliance on Signals automatically satisfies the requirements of Zone-less change detection.

One immediate benefit of going Zone-less: the Zone.js reference can be removed from angular.json, trimming about 11 KB from production bundles.

Event Coalescing in the Zone

New projects scaffolded with the Angular CLI still start with Zone.js enabled. What's changed is that the generated configuration now defaults to event coalescing:

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    […]
  ]
};

With event coalescing, Zone.js processes a single action for events that occur in rapid succession. The Angular team cites bubbling click handlers as a typical scenario where this makes a noticeable difference.

Learn Modern Angular

What’s new in Angular 18? — figure 1

Deepen your Angular expertise with our Modern Angular Workshop (offered in English and German).

Enhanced Router Redirects

Guards that need to redirect typically return a UrlTree. This approach worked, but it lacked the fine-grained control available through the navigate method—such as whether to replace the browser history entry or pass data that shouldn't appear in the URL.

Angular 18 adds a new option: guards can return a RedirectCommand. This object pairs a UrlTree with NavigationBehaviorOptions to control the router's behavior:

export function isAuth(destination: ActivatedRouteSnapshot) {
    const router = inject(Router);
    const auth = inject(AuthService);

    if (auth.isAuth()) {
        return true;
    }

    const afterLoginRedirect = destination.url.join('/');
    const urlTree = router.parseUrl('/login');

    return new RedirectCommand(urlTree, {
        skipLocationChange: true,
        state: {
            needsLogin: true,
            afterLoginRedirect: afterLoginRedirect
        } as RedirectToLoginState,
    });
}

export const routes: Routes = [
    {
        path: '',
        redirectTo: 'products',
        pathMatch: 'full'
    },
    {
        path: 'products',
        component: ProductListComponent,
    },
    {
        path: 'login',
        component: LoginComponent,
    },
    {
        path: 'products/:id',
        component: ProductDetailComponent,
        canActivate: [isAuth]
    },
    {
        path: 'error',
        component: ErrorComponent
    }
];

Setting skipLocationChange prevents the route change from appearing in the browser history, while the state property carries data to the target component without exposing it in the URL. Additional configuration options are available in the NavigationBehaviorOptions documentation.

Here's how the receiving component reads that injected state:

@Component({ … })
export class LoginComponent {
  router = inject(Router);
  auth = inject(AuthService);

  state: RedirectToLoginState | undefined;

  constructor() {
    const nav = this.router.getCurrentNavigation();

    if (nav?.extras.state) {
      this.state = nav?.extras.state as RedirectToLoginState;
    }
  }

  logout() {
    this.auth.logout();
  }

  login() {
    this.auth.login('John');
    if (this.state?.afterLoginRedirect) {
      this.router.navigateByUrl(this.state?.afterLoginRedirect);
    }
  }

}

The RedirectCommand is also compatible with the optional withNavigationErrorHandler feature:

export function handleNavError(error: NavigationError) {
  console.log('error', error);

  const router = inject(Router);
  const urlTree = router.parseUrl('/error')
  return new RedirectCommand(urlTree, {
    state: {
      error
    }
  })
}

export const appConfig: ApplicationConfig = {
  providers: [
    […]
    provideRouter(
      routes,
      withComponentInputBinding(),
      withViewTransitions(),
      withNavigationErrorHandler(handleNavError),
    ),
  ]
};

Redirects defined in the router configuration itself have also been improved. The redirectTo property, which previously accepted only a route name, now supports a function for programmatic redirection:

export const routes: Routes = [
    {
        path: '',
        redirectTo: () => {
            const router = inject(Router);
            // return 'products' // Alternative
            return router.parseUrl('/products');
        },
        pathMatch: 'full'
    },
    […],
];

This function should return either a UrlTree or a string representing the target path.

Default Content in Projection Slots

The ng-content element, which serves as a placeholder for content projection, now accepts default content. This fallback is rendered when the caller doesn't supply any content:

<div class="pl-10 mb-20">
    <ng-content>
        <b>Book today to get 5% discount!</b>
    </ng-content>
</div>

Observing Reactive Form Events

The AbstractControl base class—the foundation for FormControl, FormGroup, and related classes—now exposes an events property. This observable stream emits notifications for a variety of state changes:

export class ProductDetailComponent implements OnChanges {

  […]

  formControl = new FormControl<number>(1);

  […] 

  constructor() {
    this.formControl.events.subscribe(e => {
      console.log('e', e);
    });
  }

  […]

}

Events are delivered as instances of ControlEvent, an abstract base class with these concrete implementations:

  • FormResetEvent
  • FormSubmittedEvent
  • PristineChangeEvent
  • StatusChangeEvent
  • TouchedChangeEvent
  • ValueChangeEvent

Replaying Events After SSR

Server-side rendering (SSR) delivers the initial page quickly, but the page doesn't become fully interactive until the JavaScript bundles load and hydration completes:

Uncanny Valley at SSR

In this timeline, FMP (First Meaningful Paint) and TTI (Time to Interactive) mark key moments.

The interval between FMP and TTI—sometimes called the "uncanny valley"—presents a challenge. The user sees the rendered page, but the JavaScript needed to handle interactions hasn't arrived yet. Any clicks during this window are lost.

To address this, Angular introduces event replay. A lightweight script, loaded with the pre-rendered page, records user interactions in this gap. Once hydration finishes, those recorded events are replayed, ensuring no user action is ignored.

Event replay is an opt-in feature for provideClientHydration, activated with withEventReplay:

export const appConfig: ApplicationConfig = {
  providers: [
    […],
    provideClientHydration(
      withEventReplay()
    )
  ]
};

This capability isn't new to Google—it has been battle-tested in Wiz, Google's internal framework known for its SSR and hydration performance, as detailed in this post.

The ng-conf keynote highlighted a deepening partnership between the Angular and Wiz teams. Initially, Wiz will adopt Angular's Signals, while Angular incorporates Wiz's proven event replay mechanism.

Refined Transfer State for HTTP

With SSR, the HttpClient has traditionally cached server-side HTTP responses to avoid duplicate requests in the browser. This caching leverages an interceptor that works with the Transfer State API, which embeds cached data into the pre-rendered markup for the browser's HttpClient to retrieve.

The implementation now accounts for situations where server-side and browser-side URLs differ. You can configure a mapping object to translate between them. This example from the relevant pull request shows the setup:

// in app.server.config.ts
{
    provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
    useValue: {
        'http://internal-domain:80': 'https://external-domain:443'
    }
}

// Alternative usage with dynamic values 
    // (depending on stage or prod environments)
{
    provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
    useFactory: () => {
        const config = inject(ConfigService);
        return {
            [config.internalOrigin]: [config.externalOrigin],
        };
    }
}

There's also a security-conscious change: HttpClient no longer places responses containing Authorization or Proxy-Authorization headers into Transfer State by default. To preserve the previous caching behavior for such requests, opt in explicitly with includeRequestsWithAuthHeaders:

withHttpTransferCache({
  includeRequestsWithAuthHeaders: true,
})

DevTools Display Hydration Status

Angular DevTools now offers a visual indicator for hydration. Enabling Show hydration overlays from the bottom-right controls:

The DevTools now show which components have already been hydrated

Hydrated components are highlighted with a blue-transparent overlay, marked by a water drop icon in the top-right corner.

Adopting the App Builder

Angular 17 introduced the ApplicationBuilder, which became the default for new applications. Built on esbuild, it offers significantly faster builds than the original webpack-based builder—which remains supported—often delivering 3 to 4 times faster performance in initial testing. It also includes integrated SSR support.

Upgrading to Angular 18 prompts an automatic migration suggestion for existing applications:

When updating to version 18 you get the option to migrate to the new ApplicationBuilder

The CLI team has prioritized feature parity, so most applications should migrate smoothly with a noticeable improvement in build times.

Additional Updates

Several smaller enhancements round out the release:

  • Lazy loading with @defer now functions within npm packages.
  • A new token, _HOST_TAGNAME, exposes the current component's tag name.
  • Angular's i18n tooling now works in conjunction with hydration.
  • Deprecated modules include HttpClientModule, HttpClientXsrfModule, HttpClientJsonpModule, and HttpClientTestingModule—all superseded by standalone APIs like provideHttpClient. A migration schematic handles this automatically. - New projects get a public directory instead of an assets folder, aligning with common web development practice.
  • For Zone-less projects, the CLI no longer transforms async/await into promises. This transform existed to make them patchable by Zone.js.
  • The ApplicationBuilder now caches intermediate results, potentially accelerating subsequent builds dramatically.

Looking Ahead with Modern Angular

Modern Angular encompasses much more:

  • Signal-based reactive data flow
  • Updated Router and HttpClient APIs
  • Standalone Components, Directives, and Pipes
  • Modern control flow with @defer
  • Tools for automatic migrations
  • esbuild, SSR, and hydration adoption

Our free eBook explores these subjects across 14 chapters:

What’s new in Angular 18? — figure 5

[Download now!]

Summary

Angular 18 is characterized by polish and refinement. It brings new redirect capabilities for the router, support for default content slots, event replay functionality, and smarter handling of the Transfer State API for HTTP requests.

Beyond that, there are numerous bug fixes, a faster ApplicationBuilder thanks to caching, and an experimental Zone-less mode that points toward Angular's long-term direction for change detection.