DEV Community

Michael Small for NgRx Toolkit

Posted on

NgRx Toolkit v21

NgRx Toolkit v21

The NgRx Toolkit originates as far back as when the SignalStore was not even marked stable. As the package was in its early stages, community requests for various functionality poured in. However, the NgRx team wanted to focus on core functionality that would be broadly usable and supported to their full standard. But they provided the tools for the community to make its own tools, the signalStoreFeature function. The powerful extensibility of the SignalStore was the reason why the NgRx Toolkit team decided to start with some features that they encountered in everyday-life programming.

import { withStorageSync } from '@angular-architects/ngrx-toolkit';
import { withDevtools } from '@angular-architects/ngrx-toolkit';

const UserStore = signalStore(
  withState({ name: 'John' }),

  // Automatically synchronizes state to localStorage 
  // on each change via the key 'user'
  // (also can do session storage and IndexedDB)
  withStorageSync('user'),

  // Allows Redux Devtools (even without Redux!)
  withDevtools('user'),
);
Enter fullscreen mode Exit fullscreen mode

The NgRx Toolkit sees itself as rich set of extensions that you need in typical Angular applications. Core functionality, and its history:

  • withDevtools() that allows any SignalStore, redux/events based or not at all, to use the Redux Devtools. Your SignalStore state can be visualized in the widely used plugin by just adding the withDevtools('storeNameHere') to a store.
  • @ngrx/signals/event and withFeature, now in the core of @ngrx/signals, were incubated with their predecessors in the toolkit.
  • withStorageSync() for synchronizing state with Web Storage (localStorage/sessionStorage) and IndexedDB (via an async strategy). As easy as withStorageSync('storeNameHere'). IndexedDB was added last year by a community contribution by GitHub user mzkmnk.
  • Other features can be found in the documentation: https://ngrx-toolkit.angulararchitects.io/docs/extensions

Redux Devtools showing application state from withStorageSync()

But first: v20 minor features: withResource/withEntityResources/Mutations

Before talking about v21, there were two new features from v20 Toolkit minor versions: withResource() and its entity equivalent, withEntityResources(), as well as the Mutations API.

withResource is a feature that connects Angular's Resource API with the store. The idea: you can use a store to directly manage async data (like loading from an API), and withResource() helps you wire that in. Features unnamed and named variants. withEntityResources provides the same functionality but for @ngrx/signals/entities based stores.

The Mutations API came in a later minor, providing the other pieces of the REST experience that mutations do not cover. Mutations come as either standalone functions (httpMutation/rxMutation), as well as in a withMutation feature. The API was inspired by Angular Query and Marko Stanimirović's proposed mutations API for Angular. We also had internal discussions with Alex Rickabaugh on our design.

import { httpMutation, rxMutation, withMutations } from '@angular-architects/ngrx-toolkit';
import { withResource, withEntityResources } from '@angular-architects/ngrx-toolkit';

export const UserStore = signalStore(
  withState({ userId: undefined as number | undefined }),
  withResource(({ userId }) => ({
    detail: httpResource<User>(() => (userId === undefined ? undefined : `/user/${userId}`)),
  })),
  withMutations((store, userService = inject(UserService)) => ({
    saveUserDetail: rxMutation({
      operation: (params: Params) => {
        return userService.saveUserDetail(store.counter(), params.value);
      },
      onSuccess: (result) => {
        // ...
      },
      onError: (error) => {
        // ...
      },
    }),
    saveToServer: httpMutation({
      request: (_: void) => ({
        url: `https://httpbin.org/post`,
        method: 'POST',
        body: { counter: store.counter() },
      }),
      parse: (response) => response as UserResponse,
      onSuccess: (result) => {
        // ...
      },
      onError: (error) => {
        // ...
      },
    }),
  })),
  withEntityResources(() => resource({ loader: () => Promise.resolve([] as User[]), defaultValue: [] })),
);
Enter fullscreen mode Exit fullscreen mode

v21

Finally, actual Toolkit v21 notes. Future posts that are not our debut blogpost for the library won't always have a three act structure with a detailed backstory, we promise.

The three major items for v21 tie into topics we already discussed:

  • Better error handling for withResource() and withEntityResources()
  • Events integration into the devtools
  • Introduce clearUndoRedo in favor of store.clearStack

Upgraded withResource() and withEntityResources() error handling

The error throwing behavior of Angular's resources proved tricky for the signal store. Deadlock scenario: once a resource is in an error state and we update a signal in params, the update calls patchState, which will again access the value of the state.

After a lot of experimentation, as well as discussion with various members of the Angular community, we arrived on an error handling strategy that gives Toolkit users a few options for withResource():

type ErrorHandling = 'native' | 'undefined value' | 'previous value';

withResource(
  (store) => {
    const resolver = inject(AddressResolver);
    return {
      address: resource({
        params: store.id,
        loader: ({ params: id }) => resolver.resolve(id),
      }),
    };
  },
  // Other values: 'native' and 'previous value'
  { errorHandling: 'undefined value' }, // default if not specified
),
Enter fullscreen mode Exit fullscreen mode

Options:

  1. 'undefined value' (default). In the event of an error, the resource's value will be undefined
  2. 'previous value'. Provided the resource had a previous value, that previous value will be returned. If not, an error is thrown.
  3. 'native'. No special handling is provided, inline with default error behavior.

For withEntityResources(), it uses 'undefined value'.

Under the hood, 'previous value' and 'undefined value' proxy the value. For a detailed explanation for why this is done, check out the JSDoc for the error handling strategy

Events integration into devtools

There is some irony with this. The NgRx Toolkit brought events to the SignalStore before there was an official plugin, and the Toolkit provides Redux Devtools integration, with or without redux used. However, the now official NgRx events feature as it shaped up did not translate directly to working with the Toolkit's withDevtools.

In NgRx Toolkit v21, we are fixing this with withTrackedReducer(), an alternative approach to tracking reducer-based state changes in Redux DevTools.

To use it

  1. Replace usages of withReducer with withTrackedReducer. Note: native withReducer support is planned for the future, but that requires upstream support from @ngrx/signals.
  2. withGlitchTracking must be specified within withDevtools. If withTrackedReducer is used without the devtools and glitch tracking, runtime errors will be thrown.
// Must have all three, or runtime errors for thee!
import { withTrackedReducer, withGlitchTracking, withDevtools } from '@angular-architects/ngrx-toolkit';

export const bookEvents = eventGroup({
  source: 'Book Store',
  events: {
    loadBooks: type<void>(),
  },
});

const Store = signalStore(
  { providedIn: 'root' },
  withDevtools('book-store-events', withGlitchTracking()),
  withState({
    books: [] as Book[],
  }),
  withTrackedReducer(
    // `[Book Store] loadBooks` will show up in the devtools
    on(bookEvents.loadBooks, () => ({
      books: mockBooks,
    })),
  ),
  withHooks({
    onInit() {
      injectDispatch(bookEvents).loadBooks();
    },
  }),
);
Enter fullscreen mode Exit fullscreen mode

Introduce clearUndoRedo in favor of store.clearStack

clearStack has been deprecated in favor of a new standalone function clearUndoRedo, which does a soft reset (not setting the state to null) by default.

The hard reset can be set via options, clearUndoRedo(store, { lastRecord: null })

This change was written by Gregor Woiwode.

This change was also backported to NgRx Toolkit v19.5.0 and v20.7.0.

ngrx-toolkit-openapi-gen

We got a fantastic Christmas present from Murat Sari.

An OpenAPI generator that creates:

  • ✅ an NgRx Signal Store
  • ✅ with Resources
  • ✅ and Mutations
  • ✅ based on a Zod schema

On top of that, the generated code is genuinely beautiful – which is not something you usually see with code generators. Check it out on npm, as well as its documentation.

Future release strategy for compatible Angular versions

v21 for the Toolkit took some time due to some obstacles with the new features and fixes. However, v21 of NgRx itself was compatible with the Toolkit's later v20 releases, but only with certain overrides. To bridge the gap for an easier user experience, we released NgRx Toolkit v20.6.0, which supported both NgRx v20 and v21. The same is applicable for v20.7.0, which contains some backports of features worked on for v21.

Going forward, if there are obstacles for any coming major release of the Toolkit, we will release a minor of the Toolkit that is compatible with the next stable major of NgRx once that is available and ready to integrate.

Thank you!

We are thankful for everyone who contributes their time and expertise to discussions and code alike, as well as our users.

Though we have had various contributors, here is another thanks to the highlighted contributions from this article: thank you to mzkmnk, Murat Sari, and Gregor Woiwode.

Top comments (0)