DEV Community

Cover image for Announcing NgRx v22: Resource Extensions, Dynamic Deep Signals, a Light Theme, and more!
Marko Stanimirović for NgRx

Posted on

Announcing NgRx v22: Resource Extensions, Dynamic Deep Signals, a Light Theme, and more!

We are pleased to announce the latest major version of the NgRx framework, featuring exciting new features, bug fixes, and other updates.


Resource Extensions 🧩

Angular's resource and httpResource APIs cover a large part of async state management, but two very common requirements are not configurable at the resource level:

  • Value on loading: when a resource reloads, value() resets to undefined until the new data arrives.
  • Value on error: when a resource enters the error state, reading value() throws.

The new @ngrx/signals/resource entry point addresses both cases with resource extensions: a set of utilities for customizing the behavior of a Resource in a composable, reusable way. They wrap an existing resource and patch only the parts of its behavior that should change, while fully preserving the original resource type.

The extendResource function accepts the resource as the first argument, followed by the extensions to apply:

import { Component } from '@angular/core';
import { httpResource } from '@angular/common/http';
import {
  extendResource,
  withPreviousValueOnLoading,
  withValueOnError,
} from '@ngrx/signals/resource';

@Component({
  /* ... */
})
export class TodoList {
  // type: HttpResourceRef<Todo[] | undefined>
  readonly todosResource = extendResource(
    httpResource<Todo[]>(() => '/api/todos'),
    withPreviousValueOnLoading(),
    withValueOnError(undefined)
  );
}
Enter fullscreen mode Exit fullscreen mode

The returned resource is still the exact resource that was passed in, so no access is lost to the APIs of more specific resource types, such as WritableResource. Only value() behaves differently: it keeps the previously loaded todos while a reload is in flight, and returns undefined instead of throwing when the request fails.

Built-in Extensions

There are four built-in extensions:

  • withPreviousValueOnLoading keeps the last resolved value while the resource is reloading, which is exactly what paginated and filtered lists need to avoid flickering.
  • withValueOnLoading returns a specific fallback value while the resource is loading.
  • withPreviousValueOnError returns the last successfully resolved value when the resource enters the error state.
  • withValueOnError returns a specific fallback value when the resource enters the error state.

Registering Global Extensions

Most applications want the same loading and error behavior everywhere. The provideResourceExtensions function registers extensions for a given injector scope, which can be the application, a route, or a component:

import { bootstrapApplication } from '@angular/platform-browser';
import {
  provideResourceExtensions,
  withValueOnError,
} from '@ngrx/signals/resource';

bootstrapApplication(App, {
  providers: [provideResourceExtensions(withValueOnError(undefined))],
});
Enter fullscreen mode Exit fullscreen mode

With global extensions in place, passing the resource alone to extendResource is enough to apply them:

@Component({
  /* ... */
})
export class TodoList {
  // Global extensions are applied automatically.
  readonly todosResource = extendResource(
    httpResource<Todo[]>(() => '/api/todos')
  );
}
Enter fullscreen mode Exit fullscreen mode

Since provideResourceExtensions composes with extensions registered by parent injectors, defaults can be set once at the application level and refined per route or per component.

💡 The APIs from @ngrx/signals/resource are currently marked as experimental. Learn more in the Resource Extensions guide.


Dynamic Deep Signals 🧬

One of the best parts of signalState and SignalStore is that nested state is exposed as deeply nested signals, so a single state slice can be consumed at exactly the granularity a template needs. Until now, that only worked when the type of the slice was a plain object. As soon as a union entered the picture, which is the natural way to model "not loaded yet" or a discriminated result, the whole slice collapsed into a single flat Signal.

In v22, deep signals are created dynamically. Every object literal member of a union gets its own DeepSignal, while the remaining members (primitives, dynamic records, and so on) stay a regular Signal. Narrowing the union with a plain in check gives access to the nested signals:

import { signalStore, withState } from '@ngrx/signals';

type Book = { id: number; title: string };
type Status =
  | { type: 'success'; data: string }
  | { type: 'error'; message: string };

const BookStore = signalStore(
  withState<{ book: Book | null; status: Status }>({
    book: null,
    status: { type: 'success', data: '' },
  })
);

const store = inject(BookStore);

// 👇 object literal + null: store.book is DeepSignal<Book> | Signal<null>
if ('title' in store.book) {
  const title = store.book.title; // Signal<string>
  console.log(title());
}

// 👇 union of object literals: a DeepSignal is created for each member
// store.status: DeepSignal<{ type: 'success'; data: string }>
//             | DeepSignal<{ type: 'error'; message: string }>
if ('message' in store.status) {
  const message = store.status.message; // Signal<string>
  console.log(message());
}
Enter fullscreen mode Exit fullscreen mode

signalState slices and deepComputed results behave the same way, which makes discriminated results a pleasure to work with. Generic custom SignalStore features benefit as well: a slice typed as Entity | null, where Entity is a generic parameter, is now exposed as DeepSignalOf<Entity | null> instead of a flat Signal, so features written against generic state keep the same deep access their concrete counterparts have.

💡 Learn more in the SignalState, SignalStore, and DeepComputed guides.


SignalStoreFeatureType ♻️

Custom SignalStore features that build on top of other features have always needed their input type spelled out by hand. A feature that reads isPending and error from withRequestStatus had to redeclare both, and every change to withRequestStatus meant updating that declaration in every dependent feature.

The new SignalStoreFeatureType utility extracts the state and members produced by a feature factory, so they can be reused directly as the input type of another feature:

// with-request-status.ts
import { SignalStoreFeatureType } from '@ngrx/signals';

export function withRequestStatus() {
  return signalStoreFeature(
    withState<{ requestStatus: RequestStatus }>({ requestStatus: 'idle' }),
    withComputed(({ requestStatus }) => ({
      isPending: computed(() => requestStatus() === 'pending'),
      error: computed(() => /* ... */),
    }))
  );
}

export type RequestStatusFeature = SignalStoreFeatureType<
  typeof withRequestStatus
>;
Enter fullscreen mode Exit fullscreen mode
// with-status-message.ts
import { signalStoreFeature, type, withComputed } from '@ngrx/signals';
import { RequestStatusFeature } from './with-request-status';

export function withStatusMessage() {
  return signalStoreFeature(
    type<RequestStatusFeature>(),
    withComputed(({ isPending, error }) => ({
      statusMessage: computed(() =>
        isPending() ? 'Loading...' : error() ?? 'Ready'
      ),
    }))
  );
}
Enter fullscreen mode Exit fullscreen mode

withStatusMessage now stays in sync with withRequestStatus automatically, and the shape is declared exactly once, in the feature that owns it.

💡 Learn more in the Custom Store Features guide.


ESLint v10 Compatibility ✅

The @ngrx/eslint-plugin package is now compatible with ESLint v10. The peer dependency range covers ^9.0.0 || ^10.0.0, so teams can move to the latest ESLint release without waiting on NgRx or pinning an older version.

Support for ESLint v8 and the legacy .eslintrc config format has been dropped in this release. Only the flat config syntax is supported to register the NgRx plugin.

Huge thanks to Roli Bosch for contributing the ESLint v10 compatibility!


A Light Theme for the Docs ☀️

Last year's v21 release brought a completely redesigned website. This year it learned a second look.

The NgRx documentation site now ships with a light theme alongside the original dark one, and a theme toggle in the top left to switch between them. The selected theme is persisted, and the site respects the operating system preference on first visit. Every part of the site was converted to CSS custom properties for this, from the code highlighting theme and API reference symbols down to the contributor cards and version navigation, so both themes are first-class rather than one being a filtered version of the other.

Huge thanks to Adam Almounayar for designing and implementing the light theme!

The theme work continued after the initial release with a round of accessibility refinements across both themes: recalibrated accent and text colors that meet WCAG AA contrast on tinted backgrounds, a dedicated syntax highlighting theme per color scheme, visible keyboard focus indicators, and support for prefers-reduced-motion.

If you have not visited the docs in a while, take a look and try the toggle in the top left.


Other Improvements 👌

This release also includes a number of smaller enhancements:

  • @ngrx/store-devtools has a new actionCreators config option, which makes action creators available in the Redux DevTools dispatcher so actions can be dispatched manually from the extension. It accepts an array of action creators or an object, including the result of createActionGroup.
  • @ngrx/entity now infers the selectId return type from the adapter configuration, so it resolves to string or number instead of string | number. The EntitySelectors and MemoizedEntitySelectors types are also exported now.
  • Schematics for @ngrx/data, @ngrx/effects, and @ngrx/component-store generate code that uses the inject function instead of constructor injection.

Deprecations and Breaking Changes 💥

This release contains bug fixes, deprecations, and breaking changes. For most of these, we've provided a migration that automatically runs when you upgrade your application to the latest version.

Take a look at the version 22 migration guide for complete information regarding migrating to the latest release. The complete CHANGELOG can be found in our GitHub repository.


Upgrading to NgRx 22 🚀

To start using NgRx 22, make sure to have the following minimum versions installed:

  • Angular version 22.x
  • Angular CLI version 22.x
  • TypeScript version 6.0.x
  • RxJS version ^6.5.x or ^7.5.x

NgRx supports using the Angular CLI ng update command to update your NgRx packages. To update your packages to the latest version, run the command:

ng update @ngrx/store@22
Enter fullscreen mode Exit fullscreen mode

If your project uses @ngrx/signals, but not @ngrx/store, run the following command:

ng update @ngrx/signals@22
Enter fullscreen mode Exit fullscreen mode

Thanks to All Our Contributors and Sponsors! 🏆

NgRx continues to be a community-driven project. Design, development, documentation, and testing are all done with the help of the community.

We would like to thank the contributors who helped work towards this release of NgRx: Adam Almounayar, Arul Cornelious, bouclem, David Shortman, Denis Balan, Exequiel Ceasar Navarrete, Guillaume Turri, jase88, Julien Michel, oliv37, robert-md-or, Roli Bosch, Santosh Yadav, saudademjj, Thilo Aschebrock, Tomas Rimkus, Tummas Joensen, and zakaria-bali.

If you are interested in contributing, visit our GitHub page and look through our open issues, some marked specifically for new contributors. We also have active GitHub discussions for new features and enhancements.

We want to give a big thanks to our longtime Gold sponsor, Nx! Nx is the build system and monorepo tooling that powers the NgRx repository itself, and has been a longtime promoter of NgRx as a tool for building Angular applications.

We are also happy to announce our new Gold sponsor, CodeRabbit! CodeRabbit is an AI-powered code review tool that gives teams line-by-line feedback on every pull request.

Lastly, we want to thank our individual sponsors who have donated once or monthly.


Sponsor NgRx 🤝

If you are interested in sponsoring the continued development of NgRx, please visit our GitHub Sponsors page for different sponsorship options, or contact us directly to discuss other sponsorship opportunities.

Follow us on X and LinkedIn for the latest updates about the NgRx platform.

Top comments (0)