Every feature library that needs state management follows this pattern.
The reference implementation is libs/profile/src/lib/+state/profile/.
+state/
└── <feature>/
├── <feature>.actions.ts
├── <feature>.effects.ts
├── <feature>.module.ts
├── <feature>.reducer.ts
└── <feature>.selectors.ts
Use createAction with props<>() for payloads. Follow the naming convention [FEATURE] Event Name.
// <feature>.actions.ts
import { createAction, props } from '@ngrx/store';
import { User, IGenericError } from '@libs/entity';
export const fetchProfile = createAction('[PROFILE] Fetch Profile');
export const fetchProfileSuccess = createAction(
'[PROFILE] Fetch Profile Success',
props<{ response: User }>()
);
export const fetchProfileFailed = createAction(
'[PROFILE] Fetch Profile Failed',
props<{ error: IGenericError }>()
);Convention:
- Trigger action:
[FEATURE] Verb Noun - Success:
[FEATURE] Verb Noun Success - Failure:
[FEATURE] Verb Noun Failed
Use @ngrx/entity adapter when managing collections. Use createReducer + on().
// <feature>.reducer.ts
import { IGenericError, User } from '@libs/entity';
import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import * as fromActions from './<feature>.actions';
export const <feature>FeatureKey = '<feature>';
export interface State extends EntityState<User> {
loading: boolean;
error: IGenericError | null;
}
export const adapter: EntityAdapter<User> = createEntityAdapter<User>({
selectId: (user) => user.id,
});
export const initialState: State = adapter.getInitialState({
loading: false,
error: null,
});
export const reducer = createReducer(
initialState,
on(fromActions.fetch<Feature>, (state) => ({
...state,
loading: true,
error: null,
})),
on(fromActions.fetch<Feature>Success, (state, { response }) =>
adapter.addOne(response, {
...state,
loading: false,
error: null,
})
),
on(fromActions.fetch<Feature>Failed, (state, { error }) => ({
...state,
loading: false,
error,
}))
);
export const { selectAll } = adapter.getSelectors();Rules:
- Always reset
loadinganderroron each transition - Use the entity adapter for collections; use plain state fields for single objects
- Import actions via
* as fromActionsto avoid naming collisions
Always start from a feature selector. Compose derived selectors with createSelector.
// <feature>.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromReducer from './<feature>.reducer';
export const select<Feature>State = createFeatureSelector<fromReducer.State>(
fromReducer.<feature>FeatureKey
);
export const selectLoading = createSelector(
select<Feature>State,
(state) => state?.loading ?? false
);
export const select<Feature>List = createSelector(
select<Feature>State,
fromReducer.selectAll
);
// Convenience selector for a single item
export const select<Feature> = createSelector(
select<Feature>List,
(list) => list?.[0] ?? null
);Use createEffect + ofType + switchMap (or exhaustMap for user-initiated actions).
// <feature>.effects.ts
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { <Feature>Service } from '../../services/<feature>/<feature>.service';
import * as fromActions from './<feature>.actions';
@Injectable()
export class <Feature>Effects {
private readonly actions$ = inject(Actions);
private readonly <feature>Service = inject(<Feature>Service);
fetch<Feature>$ = createEffect(() =>
this.actions$.pipe(
ofType(fromActions.fetch<Feature>),
switchMap(() =>
this.<feature>Service.fetch<Feature>().pipe(
map((response) => fromActions.fetch<Feature>Success({ response })),
catchError((error) => of(fromActions.fetch<Feature>Failed({ error })))
)
)
)
);
}When to use each flattening operator:
| Operator | Use case |
|---|---|
switchMap |
Data fetching — cancels previous request if action fires again |
exhaustMap |
User actions (form submit) — ignores new actions while in-flight |
concatMap |
Ordered queue — processes actions one at a time |
mergeMap |
Parallel — runs all concurrently (use rarely) |
Register the feature store and effects in a dedicated NgModule imported by the page component.
// <feature>.module.ts
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { <Feature>Service } from '../../services/<feature>/<feature>.service';
import * as fromReducer from './<feature>.reducer';
import { <Feature>Effects } from './<feature>.effects';
@NgModule({
imports: [
StoreModule.forFeature(fromReducer.<feature>FeatureKey, fromReducer.reducer),
EffectsModule.forFeature([<Feature>Effects]),
],
providers: [<Feature>Service],
})
export class <Feature>StateModule {}Important:
- Always use
forFeature, neverforRootinside a lib forRootis used only once inapp.config.ts- Provide the service here, not with
providedIn: 'root', so it stays in the lazy chunk
Use store.selectSignal() for reactive reads. Dispatch in the constructor, not in ngOnInit.
@Component({
selector: 'lib-<feature>',
imports: [<Feature>StateModule],
templateUrl: './<feature>.html',
})
export class <Feature>Page {
private readonly store = inject(Store);
data = this.store.selectSignal(select<Feature>);
loading = this.store.selectSignal(selectLoading);
constructor() {
this.store.dispatch(fetch<Feature>());
}
}| State | Location | Registration |
|---|---|---|
| Cross-feature data (e.g., logged-in user) | libs/shared/store |
SharedDataStateModule in app.config.ts |
| Feature-specific data | libs/<feature>/+state |
<Feature>StateModule imported by the page component |