NgRx as a Business Logic Hub: Keeping Components Dumb and the Store Smart
State management has become a cornerstone of modern front-end development. It gained traction within the React ecosystem via Redux. In Angular applications, while Redux can be integrated, NgRx has emerged as the standard solution for reactive state management.
NgRx Store provides reactive state management for Angular apps inspired by Redux — NgRx website
Half a year ago, our team made a deliberate decision to "move everything" into NgRx. Concretely, this meant that every response from an HTTP call had to reside in the store. While this approach is often discouraged, we proceeded with it anyway.
After implementing several features using this pattern, the entire team remains fully convinced that this was the right call. This article doesn't cover the basics of state management or how NgRx operates. Instead, it focuses on why we chose to centralize all our business logic within NgRx and why you might want to consider doing the same.
Starting from Scratch
Our team kicked off a new Angular project: a reporting application named Instant Insights. The app presents data through various charts and tables, and users can adjust numerous page-wide filters, including which fields are visible and the reporting period.
Every component relies on access to these filters. A frequent challenge with component data sharing is prop drilling.
@Component({
selector: 'my-dashboard',
template: `<my-graph [filters]="filters"></my-graph>`
})
export class DashboardComponent {
// Don't need filter but necessary to pass it to my-graph
// It's called prop drilling
@Input() filters;
}
Additionally, component interaction logic becomes unwieldy. We had an abundance of @Input and @Output bindings whose only purpose was to force other components to refresh. This made the codebase hard to navigate, given the large number of filters involved.
@Component({
selector: 'app-root',
template: `
<app-sort
[defaultSort]="currentSort"
(sortChange)="updateData($event)">
</app-sort>
<app-data [sort]="currentSort"></app-data>
`,
})
export class AppComponent {
// Sort filter is stored here in the end
currentSort = 'Price';
updateData($event) {
this.currentSort = $event;
}
}
@Component({
selector: 'app-sort',
template: `
<input
*ngFor="let sort of sorts"
type="radio"
name="sort"
[value]="sort"
[checked]="sort === defaultSort"
(change)="sortClick($event)"/>{{ sort }}
`,
})
export class SortComponent {
@Input() defaultSort;
@Output() sortChange = new EventEmitter();
sorts = ['Price', 'Size'];
sortClick($event) {
const sort = $event.target.value;
this.sortChange.emit(sort);
}
}
@Component({
selector: 'app-data',
template: `<div>Data sorted by {{ sort }}</div>`,
})
export class DataComponent {
@Input() sort;
}
In such a setup, the root AppComponent manages the filter state. It also handles updating child components through @Input properties after listening for changes via @Output events.
A typical workaround is to use singleton services for shared data. This reduces verbosity but often hinders the use of the OnPush strategy. You can check this Stackblitz demo to see the service-based approach.
We turned to NgRx to manage filters and orchestrate component reloads. The filter state now resides in the store, and any component can obtain it via dependency injection of the store instance. The Redux DevTool extension allows you to inspect the store's contents and action history..
This approach has its downsides, however. Our components became less reusable, as they lost their @Input and @Output interfaces and now depend on the global store for data.
Redux DevTool extension
Under this architecture, business logic is distributed between the components and the store. To access store data, a component must subscribe to it (and remember to unsubscribe). The async pipe simplifies this in templates, but it can't help when you need store data inside component methods.
The Radical Idea: Let's Move It All
Depending on the feature, the business logic lived either in components or in the NgRx store, making the codebase inconsistent and hard to follow.
Instant Insights, like most apps, retrieves user information via HTTP requests. This data is needed across multiple components, so we had to avoid both prop drilling and making duplicate requests. Two possible solutions came up: request caching or centralized storage, with the NgRx store being the obvious candidate for the latter.
NgRx once again seemed like the perfect fit. Its power had already been demonstrated with our filter management—modifying one filter could trigger reloads across many components. It also provides a clear, reproducible architecture: actions, effects, reducers, and selectors.
NgRx architecture from official documentation
Around the same time, we learned that another team at Smart was already using NgRx. They had embraced it for all their business logic and were very satisfied with the results.
The Skepticism: Common NgRx Criticisms
Before fully committing, we did our homework and read extensively on state management. We wanted a balanced view of the pros, cons, and potential pitfalls of NgRx, just as we did for Redux in general.
NgRx = Angular + ReactiveX (RxJS in our situation)
It's crucial to note that NgRx has a unique philosophy that extends beyond traditional Redux. Its goal is to integrate Angular and ReactiveX principles into state management.
The most frequent complaint is boilerplate. Storing a single value requires creating a host of files: Action, Reducer, State, Selector, and potentially an Effect.
Note the development team worked hard to reduce the boilerplate for creating a store with NgRx version 8.
NgRx boilerplate
You might leave out index.js, which declares the root state and reducers, and you could combine the state in state.js with the reducer. Still, the amount of code remains significant.
More code can lead to slower development, trickier debugging, and a higher likelihood of bugs. This is why you shouldn't use the store for everything, only when data is shared by many components.
Dan Abramov, a React core team member and Redux author, explains this clearly in his post: You Might Not Need Redux.
Other common drawbacks include:
- A steep learning curve, especially for effects
- RxJS itself is a substantial thing to learn
- State immutability leads to copying the entire state on each update
- Having all data in a single place can feel like a god object
You can easily find more negative feedback by searching for "Redux drawbacks," "pitfalls," "hell," "sins," etc. It's wise to consider the downsides, not just the advantages, to make an informed decision for your specific context.
The Validation: A Few Months In
As you might expect, we consider this transition a success. It wasn't easy, and we're still learning NgRx's subtleties. The biggest hurdles have been effects and, more specifically, RxJS.
RxJS merge operator marble diagram (from https://rxmarbles.com/)
In summary, if you're comfortable with reactive programming, observables, and ReactiveX, the NgRx learning curve isn't too bad. The core state management concepts are even easier to grasp and debug.
Centralizing our business logic in NgRx gave us a very clear, consistent architecture. The monolithic service methods were decomposed into smaller, more focused reducers, selectors, and effects.
This results in smaller pieces of code easier to test, more robust and less bug-prone. Note reducers must be pure functions (functional programming concept)
This structure also made it easy to divide work on new features:
- Define the action and write the reducer
- Implement data fetching logic in effects
- Subscribe to store data in components via selectors
Our large, complex Angular components have transformed into simple (or "dumb") components. The "smart" part—loading and manipulating data—has been abstracted away, leaving the component to act primarily as a template.
Redux introduced in its early ages the Smart and Dumb component concept
export class ItemListComponent {
user$ = this.store.select(selectUser);
config$ = this.store.select(selectConfig);
items$ = this.store.select(selectItems);
constructor(private store: Store<RootState>) {}
addItem (item) {
this.store.dispatch(addItem(item.id));
}
removeItem (item) {
this.store.dispatch(removeItem(item.id));
}
}
From NgRx's viewpoint, you don't even need "smart" components. All your components become "dumb." They use selectors to pull formatted data and dispatch actions in response to user interactions. This describes the majority of our components now.
Do you remember the earlier example with SortComponent and DataComponent? Here's how that looks with NgRx.
@Component({
selector: 'app-sort',
template: `
<input
*ngFor="let sort of sorts"
type="radio"
name="sort"
[value]="sort"
[checked]="sort === (filters$ | async).sort"
(change)="updateSort($event)"/>{{ sort }}
`,
})
export class SortComponent {
sorts = ['Price', 'Size'];
filters$ = this.store.select(selectFilters);
constructor(private store: Store<RootState>){}
updateSort($event) {
const sort = $event.target.value;
this.store.dispatch(selectSort(sort));
}
}
@Component({
selector: 'app-data',
template: `<div>Data sorted by {{ (filters$ | async).sort }}</div>`,
})
export class DataComponent {
filters$ = this.store.select(selectFilters);
constructor(private store: Store<RootState>){}
}
This is quite similar to the singleton services approach, apart from the NgRx boilerplate. For our team, effects are the real game-changer. They let us centralize all filter interactions in one place.
For example, changing the sorting filter might conditionally impact another filter and trigger a data reload. This logic isn't duplicated anywhere else. The Redux DevTool extension shows each action in the correct sequence, making it easy to trace.
Some Fine-Tuning
NgRx provides a clear, opinionated architecture. This means it can sometimes restrict what you're able to do. Usually, that's a sign that you shouldn't be doing it.
The architecture is also quite flexible. Both selectors and effects can access the entire application state.
Though sometimes you need to break the rules. For instance, you may pay attention to parameterized selectors.
Let's loop back to our reporting app's filter scenario. The key detail is how filter changes trigger graph reloads.
Graph loading effect
The loadGraph$ effect listens for a dispatch of actions that occur whenever a filter is changed. It then decides whether to issue a new action to load the data, based on the current filter state.
This is a Context based action decider effect. Read more about it in NgRx: Patterns and Techniques
The interesting part is that it doesn't always trigger a new action. For instance, the first addField action might not require a reload. This behavior can make the effect a bit difficult to follow, and it tends to grow large as more reload conditions are added.
One idea was to dispatch a special DoNothing action when a reload wasn't required. Ultimately, we opted for a different strategy: separating the logic that decides *when* to reload from the logic that actually performs the graph data load.
Composing effects to split business logic
While it looks more complex at first glance, we find it clearer. The loadGraph$ effect is now solely responsible for fetching the data. The reloadGraph$ effect listens for all filter changes and decides when to initiate a graph reload.
This is an example of effect composition, where each effect has a single, defined responsibility. This technique is powerful, but it should be used judiciously. Chaining too many effects can make it difficult to track the flow of actions across your application, even with useful tools like the Redux DevTool extension.
A tracing solution does exist, but it's not yet integrated into NgRx. For an alternative that uses static analysis, check out the NgRx-Vis project.
Wrapping Up
Thank you for reading! This has been a recap of our team's journey with NgRx. It's challenging to learn, but the payoff has been substantial. Committing to this shift was certainly worthwhile for us.
Remember that every project has unique needs. Instant Insights, for instance, is primarily a view-only application; it doesn't have complex forms for data editing or persistent mutations. This approach might not be necessary for smaller or simpler projects either.
Please feel free to share your own experiences or ask any questions in the comments.
