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 {}
We can now do this:
bootstrapApplication(AppComponent, {
providers: [
provideStore({
app: appReducer,
router: routerReducer
}),
provideEffects([AppEffects, LoginEffects])
],
});
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'
);
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(),
},
});
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());
}
}
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:
- Define a state interface and initial state for the new feature
- Write a reducer using that initial state
- Write a feature selector using the
createFeatureSelector
function - 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
);
- Then register the reducer in the
forFeature
function of theStoreModule
:
@NgModule({
imports: [
StoreModule.forFeature('feature', featureReducer),
]
})
export class FeatureModule {}
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,
}),
),
),
});
So now, we can register the new feature just like this:
@NgModule({
imports: [
StoreModule.forFeature(Feature),
]
})
export class FeatureModule {}
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);
}
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)
);
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)
),
}),
});
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);
}
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});
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)],
});
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},
);
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
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
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)
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 noStoreModule.forRoot()
and when I add that in (with no config param) I getNullInjectorError: 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.
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 withStoreModule.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
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!
Hey, no worries.
StoreModule
has aforFeature
method, notforChild
. You do not callforRoot
multiple times, instead call it once in the root module, maybe with empty object if you do not have global reducers, and then callforFeature
in lazy-loaded modulesHmm 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 :)
Great information.