Organizing NgRx Projects for Maintainability
In the previous article, we reviewed certain anti-patterns to steer clear of when working with NgRx in an Angular application. Now, let's shift focus to beneficial approaches. None of the following suggestions are strictly mandatory; however, they will substantially improve the ergonomics of managing a codebase centered around NgRx. Let's explore the key recommendations.
Maintain a Clean Project Layout
This point stands as the most critical takeaway here. Adopting NgRx transforms the way we compile, arrange, and manage Angular projects, especially when implemented comprehensively. A logical and uniform directory structure is fundamental. There are a few tactics you can adopt immediately.
Implement Feature Stores
In any moderately complex Angular application, you will encounter multiple lazy-loaded modules. Typically, these modules rely on state that pertains only to their own UI components. Therefore, allocating the entire initial application state to a root store is suboptimal. Feature states should be registered dynamically, only when their corresponding module is loaded lazily.
@NgModule({
StoreModule.forRoot({root: rootReducer}),
EffectsModule.forRoot([RootEffects]),
})
export class AppModule {}
Inside a lazy-loaded module, the configuration looks like this:
@NgModule({
StoreModule.forFeature({user: userReducer}),
EffectsModule.forFeature([UserEffects]),
})
export class UserModule {}
Standardize the Structure of Feature States
An effective strategy involves creating a state directory within each lazy-loaded module that manages its own data. This folder should contain five consistent files to separate concerns:
- state.ts (defines the interface for the store and initial state)
- reducers.ts (contains all reduction logic)
- actions.ts (lists the actions dispatched)
- selectors.ts (contains named selectors)
- effects.ts (manages side effects)
Should you integrate @ngrx/entity, an additional file named "adapter.ts" can be included to store adapter configurations.
Refine File Naming Conventions
While the previous section uses generic names like "state.ts" or "selectors.ts", these are not advisable, purely based on their ambiguity. It’s much more effective to prefix them with the specific feature. For instance, use "articles.reducers.ts" or "bookmarks.effects.ts". This strategy prevents having multiple folders files with identical names. More importantly, a quick search by file name (e.g., searching for "bookmarks.effects") yields precise results instead of a list of generic "actions.ts" files where you have to manually inspect directories to locate the correct one.
Use a String Enum for Feature Names
Since you’ll have multiple feature states, each requiring a unique identifier, consolidating these names into an enum is wise. This allows for easy refactoring if feature names need to change later. Here is a brief example of this enum:
export enum Features {
User = 'user',
Article = 'article',
Bookmark = 'bookmark'
}
Subsequently, in a file like user.module.ts, the registration looks like:
@NgModule({
StoreModule.forFeature({[Feature.User]: userReducer}),
EffectsModule.forFeature([UserEffects]),
})
export class UserModule {}
And within bookmarks.selector.ts, you can seamlessly tap into the feature state using:
const bookmarks = createFeatureSelector(Features.Bookmark);
Rely on Named Selectors
This is a simple piece of advice. Consider the following code:
export class MyComponent {
noUsers$ = this.store.select(state => state.user.userList.items.length === 0);
constructor(
private readonly store: Store,
) {}
}
While this code functions, it harbors minor but nagging issues. First, examining this selector does little to clarify its purpose. You must carefully parse the entire expression, which becomes increasingly time-consuming with complex logic. Additionally, this inline approach isn’t reusable or easily abstracted. These are small concerns, and many apps won't hit a wall here. However, a frustrating situation arises if you later need to combine this logic with another selector using createSelector. For that, you would have to refactor this inline code into a named variable anyway. Why not skip that middle step now?
// selectors.ts
const noUsers = createSelector(userState, state => state.userList.items.length === 0);
// in component.ts
export class MyComponent {
noUsers$ = this.store.select(noUsers);
constructor(
private readonly store: Store,
) {}
}
Converting to named selectors also provides benefits like centralizing your data queries and enabling memoization.
Improve Reducer and Effect Code
Optimize effects and state logic to streamline the developer experience and eliminate duplication. This often involves creating small abstractions.
Write Helper Functions and Custom Operators
Occasionally, our effect pipelines require specific checks or operations, such as verifying a payload condition or checking for an item's existence. In these situations, it is practical to author small custom operators or helper functions. A good example of this can be seen in this tweet by Serkan Sipahi. Here is a sample operator in action:
const truthy = <T extends unknown>(param = true) => filter<T>(value => !!value === param);
Another practice I advocate is creating generator functions for action sets that often appear together. For instance, when managing an HTTP request via NgRx effects, you typically dispatch three actions: loadData, loadDataSuccess, and loadDataError. Let’s look at defining them:
const loadData = createAction(
'[Some Component] Load Data',
props<{payload: Params}>,
);
const loadDataSuccess = createAction(
'[Some Component] Load Data Success',
props<{payload: ResponseData}>,
);
const loadDataError= createAction(
'[Some Component] Load Data Error',
props<{payload: ResponseError}>,
);
The sole distinctions between these actions are their suffixes (absent, "Success", and "Error") and their payload types. To avoid verbosity, we can write a function that produces a tuple of all three:
export function createHTTPActions<RequestPayload = void, ResponsePayload = void, ErrorPayload = ResponseError>(
actionType: string,
): [
ActionCreator<string, (props?: RequestPayload) => {
payload: RequestPayload;
} & TypedAction<string>>,
ActionCreator<string, (props?: ResponsePayload) => {
payload: ResponsePayload;
} & TypedAction<string>>,
ActionCreator<string, (props?: ErrorPayload) => {
payload: ErrorPayload;
} & TypedAction<string>>,
] {
return [
createAction(actionType, (payload: RequestPayload) => ({payload})),
createAction(
`${actionType} Success`,
(payload?: ResponsePayload) => ({payload})),
createAction(`${actionType} Error`, (payload: ErrorPayload) => ({payload})),
];
}
On the surface, this might seem unnecessarily intricate. However, in practice, it allows for the elegant creation of these actions with a single call to clean up our code:
const [ loadData, loadDataSucces, loadDataError ] = createHTTPActions<Parameters, ResponseData, ResponseError>('[Some Component] Load Data');
Within a single line of code, we now have all necessary actions for a data fetch lifecycle:
this.store.dispatch(loadData({/* the actual request data */}));
Thinking critically about your actions will clear the path to finding more use cases for these boilerplate-reducing functions.
Leverage @ngrx/entity and @ngrx/component-store
Reducers often harbor some of the most repetitive logic pattern in a project. Take, for example, this CRUD handling process:
// complex transformations of data in lists, like adding, deleting
// updating and filtering is done manually, in a tedious way
const articleReducer = createReducer(
{articles: [], // some other state too maybe},
on(fromArticles.addArticle, (state, {payload}) => ({
...state,
articles: [...state.articles, payload],
})),
on(fromArticles.removeArticle, (state, {payload}) => ({
...state,
articles: state.articles.filter(article => article.id !== payload.id),
})),
on(fromArticles.updateArticle, (state, {payload}) => {
const article = state.articles.find(a => a.id === payload.id);
// unpleasant logic to update and insert
// the updated article back into the array of articles
}),
on(fromArticles.addManyArticles, (state, {payload}) => ({
...state,
articles: [...state.articles, ...payload],
})),
// probably more similar code to set, setMany, updateMany and so on
);
Again, these snippets aren't dreadful, but they become incredibly repetitive when managing multiple collections of entities. Beyond syntax, other complexities arise—sorting, performance tuning, and memoization. The @ngrx/entity package, designed specifically for managing collections, solves these problems. The same reducer logic can be dramatically simplified:
// adapter helper functions will be used instead of performing
// data transforming logic manually
const articleAdapter = createAdapter<Article>();
const articleReducer = createReducer(
{articles: articleAdapter.genIntiialState(), // some other state too maybe},
on(fromArticles.addArticle, (state, {payload}) => {
...state,
articles: articleAdapter.addOne(payload, state.articles),
}),
on(fromArticles.removeArticle, (state, {payload}) => {
...state,
// removing an object from a list is now just a one-liner
articles: articleAdapter.removeOne(payload, state.articles),
}),
on(fromArticles.updateArticle, (state, {payload}) => {
...state,
// updating is also very easy
articles: articleAdapter.update(payload, state.articles),
}),
on(fromArticles.addManyArticles, (state, {payload}) => {
...state,
articles: articleAdapter.addMany(payload, state.articles),
}),
// more simple one-liners for the entire logic
);
This is clearly an upgrade. As an added benefit, @ngrx/entity shifts our underlying storage to something closer to a JavaScript Map, improving access speed and entity updates compared to standard arrays. It also ships with pre-built memoized selectors.
That said, not every list requires this library. If you merely store and display a list without modification, @ngrx/entity is likely overkill. But if your operations include updates, composition, filtering, or sorting, utilizing entity is definitely a win. Familiarity with it is highly valuable for NgRx development. Explore it further in this and this article.
A fantastic modern addition is the @ngrx/component-store library. This tool provides a straightforward class for localized reactive state within components. It aids in sharing state across sibling components and could even replace a heavy global store for smaller projects. More insights are available in this guide.
Enforce Quality with Linters
In my previous piece, we touched on several anti-patterns. That was not an exhaustive list, and tracking these manually can be tiresome. Fortunately, community-driven tools exist to codify these standards. A quite valuable resource is the eslint-plugin-ngrx by Tim Deschryver. The rules integrated here guard against issues like combining selectors with combineLatest, sequential dispatches, and dispatches from effects, among many others.
(Note: I assisted by adding rule descriptions to eslint-plugin-ngrx, but there are still gaps. If you’re interested, you can contribute to completing the documentation.)
Closing Thoughts
This list certainly doesn't cover exhaustive optimization techniques for NgRx. However, by correcting the common pitfalls shown earlier and applying the strategies discussed above, you can make your application more graceful, encourage easier scaling, and enhance maintainability for years to come.
