Standalone APIs

Angular 14 introduced standalone components, which became stable in v15. While these standalone components work alongside NgModule-based setups—meaning the existing NgRx workflow remains valid—the NgRx team has shipped dedicated standalone APIs to support architectures that move away from NgModule. Consider the former approach:

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

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

Now, we have this option:

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

While this shift might seem modest, it's reassuring to see NgRx keeping pace with Angular's evolving ecosystem.

Action groups

NgRx v14 introduced a utility called createActionGroup that simplifies defining multiple actions. It takes a source for the actions—following the "good action hygiene" principle—along with an object mapping events to their corresponding props. So, instead of spelling out actions individually like 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 define them more compactly:

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(),
  },
});

After this, the LoginActions object holds every action we've declared, allowing us to reference them in this manner:

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());
  }
}

Observe how our action types, like Login Failure written with spaces and capitalization, get transformed into loginFailure—a camelCase property name. This transformation relies on TypeScript's template literal types and mapped types. The implementation behind this is quite intriguing, and if you're into TypeScript, it's well worth a look.

Features

Traditionally, adding a new feature—often within lazy-loaded modules—followed this sequence:

  1. Define a state interface and establish an initial state for the feature.
  2. Construct a reducer based on that initial state.
  3. Employ the createFeatureSelector function to set up a feature selector.
  4. Leverage that feature selector alongside createSelector to craft our selectors, frequently involving repetitive boilerplate:
export const selectFeature = createFeatureSelector<FeatureState>(
    'feature',
);

export const selectFeatureData = createSelector(
  selectFeature,
  (state) => state.data
);
  1. Finally, register the reducer within the forFeature method of the StoreModule:
@NgModule({
  imports: [
    StoreModule.forFeature('feature', featureReducer),
  ]
})
export class FeatureModule {}

With the latest update, this entire process can be consolidated into one call to createFeature. This function takes a name and a reducer, and returns a Feature object offering:

  • reducer: the reducer we supplied
  • selectors: automatically generated selectors derived from the initial state

This means a feature can now be constructed 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,
            }),
        ),
    ),
});

Subsequently, registering the feature is straightforward:

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

The beauty here is that all selectors come predefined, so they can be used directly:

@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);
}

You'll see the selector has been automatically named selectData, drawn from the data property appearing in the initial state. This is accomplished using the same template literal types and mapped types trick discussed for action groups.

This update is particularly impactful, as it cuts down significantly on the boilerplate typically written when introducing new features into an existing store.

Additional selectors for feature slices

The default set of selectors generated by createFeature is limited to the basic ones derived directly from the shape of the initial state. However, there are many scenarios where you need something more tailored. For instance, you might want a selector that exposes the feature's data as an array rather than a key-value object. In the past, the typical solution required the createSelector function:

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

The downside of that older pattern was a certain degree of separation between the selector and the feature it belonged to. Nothing prevented these selectors from being defined far away from the feature itself, which could make the codebase harder to follow.

Thanks to the extraSelectors property on the createFeature function, this is now a thing of the past. You can declare any additional selectors directly within the feature definition:

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)
        ),
    }),
});

Once declared, these selectors can be consumed in your components just like any other:

@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);
}

A great use case for this is consolidating multiple selectors into a single, dedicated view-model selector.

Defining effects as functions

This is a significant quality-of-life improvement. Previously, writing an effect meant creating a whole class, injecting the necessary services, and Actions — a substantial amount of ceremonial code. The new approach leverages the power of the inject function, allowing you to define an effect as a lone, standalone 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});

You can now define as many of these functional effects as you like in a single file. When you're ready to plug them into your application, simply import them all at once and register them:

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

bootstrapApplication(AppComponent, {
  providers: [provideEffects(featureEffects)],
});

There's an even more concise syntax, too. Instead of calling inject inside the function body, you can supply the dependencies as default parameters when the effect is created:

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},
);

Impact on your project

These shifts have a tangible impact on how you organize your source code. A traditional structure might have kept feature-related concerns scattered across different folders:

└── 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

The new createFeature API encourages a different structure. It's now practical and clean to consolidate everything related to one feature in a single, dedicated 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

This change also brings several secondary benefits. It reduces ambiguity about where a selector for a feature lives. It simplifies unit testing by keeping all the feature's logic in one cohesive block. And of course, it cuts down on the total amount of boilerplate you need to write.

Final thoughts

NgRx continues to evolve at a rapid pace, just like the rest of the Angular ecosystem. These kinds of advancements are a positive sign for the community, as they streamline development and promise a more productive future for Angular applications.