DEV Community

Cover image for The New NgRx
Armen Vardanyan for This is Angular

Posted on

The New NgRx

In recent months, NgRx, the popular state management library for Angular, has received several significant updates in regards to its API and the way it works. In this atrticle, we will explore what changed, and how this affects the way we use NgRx in our applications.

Main changes are as follows:

  • Standalone APIs
  • Action groups
  • Features
  • Extra selectors on features
  • Functional effects

Let's dive deeper into each of these updates:

Standalone APIs

From Angular 14, standalone components are a thing in Angular (stable as of v15). Standalone components interop with NgModule-s, so we could just continue using NgRx the way we used too, but the team went on and provided standalone API-s to move away from NgModule based architectures. So, instead of this:

@NgModule({
  imports: [
    StoreModule.forRoot({

        app: appReducer,
        router: routerReducer
    }),
    EffectsModule.forRoot([AppEffects, LoginEffects])
  ]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

We can now do this:

bootstrapApplication(AppComponent, {
    providers: [
        provideStore({
            app: appReducer,
            router: routerReducer
        }),
        provideEffects([AppEffects, LoginEffects])
    ],
});
Enter fullscreen mode Exit fullscreen mode

This is not a very significant change, but it's nice to know NgRx grows in sync with latest developments in Angular.

Action groups

Starting from v14, NgRx includes a helper function to allow us to definte multiple actions at ease, called createActionGroup. It works by accepting a source of actions (as in "good action hygiene") and a dictionary of events and their respective props, so instead of this:

export const login = createAction(
  '[Login Page] Login',
  props<{ username: string; password: string }>()
);

export const loginSuccess = createAction(
  '[Login Page] Login Success',
  props<{ user: User }>()
);

export const loginFailure = createAction(
  '[Login Page] Login Failure',
  props<{ error: any }>()
);

export const loginPageOpened = createAction(
    '[Login Page] Login Page Opened'
);
Enter fullscreen mode Exit fullscreen mode

We can now do this:

export const LoginActions = createActionGroup({
  source: '[Login Page]',
  events: {
    'Login': props<{ username: string; password: string }>(),
    'Login Success': props<{ user: User }>(),
    'Login Failure': props<{ error: any }>(),
    'Login Page Opened': emptyProps(),
  },
});
Enter fullscreen mode Exit fullscreen mode

Now, the LoginActions object will contain all the actions we defined, so we can use them like this:

export class LoginPageComponent implements OnInit {
  constructor(private store: Store) {}

  login(username: string, password: string) {
    this.store.dispatch(LoginActions.login({ username, password }));
  }

  ngOnInit() {
    this.store.dispatch(LoginActions.loginPageOpened());
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that our action types like Login Failure written in plain English are converted to loginFailure - a camel case variable name. This magic is done using TypeScript's template literal types and the mapped types. The source code for this is pretty fascinating, if you're a TypeScript enthusiast, I strongly recommend checking it out.

Features

For a long time, we introduced new features (usually in lazy loaded modules) with the following pattern:

  1. Define a state interface and initial state for the new feature
  2. Write a reducer using that initial state
  3. Write a feature selector using the createFeatureSelector function
  4. Use that feature selector to define our selectors with the createSelector function, mostly just boilerplate like the following:
export const selectFeature = createFeatureSelector<FeatureState>(
    'feature',
);

export const selectFeatureData = createSelector(
  selectFeature,
  (state) => state.data
);
Enter fullscreen mode Exit fullscreen mode
  1. Then register the reducer in the forFeature function of the StoreModule:
@NgModule({
  imports: [
    StoreModule.forFeature('feature', featureReducer),
  ]
})
export class FeatureModule {}
Enter fullscreen mode Exit fullscreen mode

But now, all of this functionality can be reduced into a single function, called createFeature, which accepts a name and a reducer and returns a Feature object, which contains the following:

  • reducer: the reducer we passed in
  • selectors: the related selectors deduced from the initial state

So now we can build a feature just like this:

const initialState: FeatureState = {
    data: null,
    loading: false,
    error: null,
};
export const Feature = createFeature({
    name: 'feature',
    reducer: createReducer(
        initialState,
        on(
            FeatureActions.loadFeature,
            (state) => ({
                ...state,
                loading: true,
            }),
        ),
        on(
            FeatureActions.loadFeatureSuccess,
            (state, { data }) => ({
                ...state,
                data,
                loading: false,
            }),
        ),
        on(
            FeatureActions.loadFeatureFailure,
            (state, { error }) => ({
                ...state,
                error,
                loading: false,
            }),
        ),
    ),
});
Enter fullscreen mode Exit fullscreen mode

So now, we can register the new feature just like this:

@NgModule({
  imports: [
    StoreModule.forFeature(Feature),
  ]
})
export class FeatureModule {}
Enter fullscreen mode Exit fullscreen mode

And the amazing thing is that we get all the selectors for free, so we can use them like this:

@Component({
  selector: 'app-feature',
  template: `
    <div *ngIf="loading$ | async">Loading...</div>
    <div *ngIf="error$ | async">Error!</div>
    <div *ngIf="data$ | async as data">
      <div *ngFor="let item of data">
        {{ item }}
      </div>
    </div>
  `,
})
export class FeatureComponent implements OnInit {
    readonly store = inject(Store);
    readonly data$ = this.store.select(Feature.selectData);
    readonly loading$ = this.store.select(Feature.selectLoading);
    readonly error$ = this.store.select(Feature.selectError);
}
Enter fullscreen mode Exit fullscreen mode

As you can notice, the selector was automatically named selectData from the data property in the initial state. This is done using the same template literal types and mapped types magic we saw in the action groups.

Now this one is significant, as it very much reduces lots of boilerplate we write when defining new features in an existing store.

Extra selectors on features

Now the createFeature function only creates the default, basic selectors inferred from the data in the initial state. But what if we want to create more selectors? For example, we want to create a selector that returns the data as an array, instead of an object. Previously, we could do this by using the createSelector function:

export const selectFeatureDataAsArray = createSelector(
  Feature.selectData,
  (state) => Object.values(state.data)
);
Enter fullscreen mode Exit fullscreen mode

But this approach slightly decouples this selector from the feature, as it's possible not to define them in the same place.

But now, we can define extra selectors for a feature by using the extraSelectors property of the createFeature function:

export const Feature = createFeature({
    name: 'feature',
    reducer: createReducer(
        initialState,
        on(
            FeatureActions.loadFeature,
            (state) => ({
                ...state,
                loading: true,
            }),
        ),
        on(
            FeatureActions.loadFeatureSuccess,
            (state, { data }) => ({
                ...state,
                data,
                loading: false,
            }),
        ),
        on(
            FeatureActions.loadFeatureFailure,
            (state, { error }) => ({
                ...state,
                error,
                loading: false,
            }),
        ),
    ),
    extraSelectors: ({selectData}) => ({
        selectDataAsArray: createSelector(
            selectData,
            (data) => Object.values(data)
        ),
    }),
});
Enter fullscreen mode Exit fullscreen mode

And now we can use it in our components:

@Component({
  selector: 'app-feature',
  template: `
    <div *ngIf="data$ | async as data">
      <div *ngFor="let item of data">
        {{ item }}
      </div>
    </div>
  `,
})
export class FeatureComponent implements OnInit {
    readonly store = inject(Store);
    readonly data$ = this.store.select(Feature.selectDataAsArray);
}
Enter fullscreen mode Exit fullscreen mode

Note: also use this to combine selectors into view model selectors

Functional effects:

This one is a fun one: now we do not need to write classes with NgRx at all: oreviously, we needed to create classes to inject our services and the Actions Observable to create effects and work with them. Now, with the inject function, we can just inject the services we need and use them directly in the effect function:

export const loadFeature = createEffect(() => {
    const actions = inject(Actions);
    const featureService = inject(FeatureService);
    return actions.pipe(
        ofType(FeatureActions.loadFeature),
        mergeMap(() => featureService.loadFeature().pipe(
            map((data) => FeatureActions.loadFeatureSuccess({ data })),
        )),
        catchError((error) => of(
            FeatureActions.loadFeatureFailure({ error }),
        )),
    );
}, {functional: true});
Enter fullscreen mode Exit fullscreen mode

Now we can define a bunch of effects like this, in a file, import them all where we need to register them, and do it:

import * as featureEffects from './users.effects';

bootstrapApplication(AppComponent, {
  providers: [provideEffects(featureEffects)],
});
Enter fullscreen mode Exit fullscreen mode

You can also shorten this a bit by providing the dependencies as default arguments when creating the effect:

export const loadFeature = createEffect(
    (
        actions = inject(Actions),
        featureService = inject(FeatureService),
    ) => actions.pipe(
        ofType(FeatureActions.loadFeature),
        mergeMap(() => featureService.loadFeature().pipe(
            map((data) => FeatureActions.loadFeatureSuccess({ data })),
        )),
        catchError((error) => of(
            FeatureActions.loadFeatureFailure({ error })),
        ),
    ),
    {functional: true},
);
Enter fullscreen mode Exit fullscreen mode

Implications

As a result of these changes, folder structure may be affected. If previously we had something like this:

└── store/
    ├── reducers/
    │   ├── app.reducer.ts
    │   ├── feature.reducer.ts
    │   └── other.reducer.ts
    ├── actions/
    │   ├── app.actions.ts
    │   ├── feature.actions.ts
    │   └── other.actions.ts
    ├── selectors/
    │   ├── app.selectors.ts
    │   ├── feature.selectors.ts
    │   └── other.selectors.ts
    └── effects/
        ├── app.effects.ts
        ├── feature.selectors.ts
        └── other.selectors.ts
Enter fullscreen mode Exit fullscreen mode

But now, with the createFeature capability, we can also reduce our folder boilerplate by grouping all the files related to a feature in a single folder:

└── store/
    ├── features/
    │   ├── app.feature.ts
    │   ├── feature.feature.ts
    │   └── other.feature.ts
    ├── actions/
    │   ├── app.actions.ts
    │   ├── feature.actions.ts
    │   └── other.actions.ts
    └── effects/
        ├── app.effects.ts
        ├── feature.selectors.ts
        └── other.selectors.ts
Enter fullscreen mode Exit fullscreen mode

Also, this means less confusion between feature selectors, improved unit testing, and, of course, again, less boilerplate.

In Conclusion

NgRx is, as all Angular ecosystem, a rapidly developing library, and its advance means a brighter future for Angular projects.

Top comments (6)

Collapse
 
oldschoolbg profile image
oldschoolbg

Quick question; I've migrated my (very large) NGRX implementation to this new way of organising the code however I'm unclear how to go about providing the Store correctly in the Modules.

I'm getting NullInjectorError: No provider for ReducerManager! when I have no StoreModule.forRoot() and when I add that in (with no config param) I get NullInjectorError: No provider for InjectionToken @ngrx/store Root Store Provider!

I do not have any reducers any more to provide to the forRoot method.

I would be very grateful if you could share an example project maybe of how one sets up a project with this new way of working as I cannot find any documentation at all, and the ngrx website still has the old way of organising in all their examples.

Thanks again in advance.

Collapse
 
armandotrue profile image
Armen Vardanyan

Hi, thanks for your question.

I think you can provide the store with an empty list of reducers, as in StoreModule.forRoot({}) and just add features with StoreModule.forFeature(myFeature). That should fix the issue.

I don't have working examples of this right now (I have worked with this new approaches in private repos), but when I have some in the future, I will post it

Collapse
 
oldschoolbg profile image
oldschoolbg

Hi thanks for such an awesome quick reply - greatly appreciated.

I've tried this again (I had already tried it) and it still throws the same error

My project has a core module (with its own state) and two other modules that both have their own state (and also sometimes call into the core state, but never across between the two modules).

I cannot see a "forChild(...) method on StoreModule as we would for router so i've applied the .forRoot(..) to each module.

If you do get an example you can share that would be awesome; for now I'm going to think about reverting back to the "old" style as I need to ship this relatively urgently :D

Thanks again!

Thread Thread
 
armandotrue profile image
Armen Vardanyan

Hey, no worries.
StoreModule has a forFeature method, not forChild. You do not call forRoot multiple times, instead call it once in the root module, maybe with empty object if you do not have global reducers, and then call forFeature in lazy-loaded modules

Thread Thread
 
oldschoolbg profile image
oldschoolbg

Hmm I'd tried that as well already and get the NullInjectorError: No provider for InjectionToken @ngrx/store Root Store Provider! error in this case.

Thanks again for your quick reply, and suggestions :)

Collapse
 
pterpmnta profile image
Pedro Pimienta M.

Great information.