India has over 12 million Kirana stores that aggregate a huge share of the Indian E-commerce market when it comes to food and groceries. In 2021, almost a million Kirana stores opened their doors to E-commerce and technology. Thus it’s evident that the online grocery selling industry is growing at an unprecedented rate.
As the pioneers of India’s largest automated shipping platform, we also engineer some awesome E-commerce platforms under our belt. One of which is MyKirana.com, which lets you order groceries from your local Kirana store powered by Hindustan Unilever.
Since its inception, MyKirana has boarded over 3000 sellers and has processed more than 3 million orders. With more than 35K orders a week, our web and mobile apps cater to the grocery needs of a large number of weekly active users. These numbers simply mean that every new feature we ship to the platform has to be carefully architectured.
Besides providing a seamless experience to the users, we also need to ensure that we’re optimizing communication between our backend and frontend. So we decided to implement global state management in our Angular web app using NgRx. In this post, I’ll walk you through our thought process, implementation, and how we dealt with the caveats around it.
The Traditional Approach
Let’s say you visit the offers page of the MyKirana app. Initially, this page loads a blank HTML document.
Then, the page communicates with a backend service that fetches the data for this page. At this point, you probably see a skeleton loader or a loading screen for a fraction of a section. Then, the page populates the HTML page with the required data.
What happens when you scroll down this offers page? Or what happens when you click the pagination button at the bottom right? We now fetch some more paginated data using the same process. So the entire process repeats itself, this time for page 2 instead of page 1.
Finally, when you navigate to another page or close the app, the offers page unmounts itself from the DOM. At this point, all local variables, state variables, functions scope are destroyed from the memory.
To sum up, specific user interactions on UI elements in a page or component are associated with some data fetching action. The data, however, has a scope that’s limited by the life of that page or component.
Problems with the Traditional Approach
The above workflow is what most applications follow on the frontend, and so did we. But there are some problems with it. And of course, a huge room for optimization.
What happens when you go back from page 2 to page 1?
You’d probably repeat the entire process again for page 1 instead of page 2, right?
But we already did that the first time we visited the offers page, so why do it again?
In another scenario, imagine you navigated to the home page from the offers page. Then again, came back to the offers page. Your offers page again goes through the entire process of interacting with the service, fetching, and populating data. This is because the first time you navigated away from it, its local variables were destroyed from the memory. So when you came back to it, it underwent a fresh mounting phase.
However, it would be nice if we could store the result of our previous API call somewhere that we could simply use on subsequent loading of the same page. To put it differently, we could cache the data for different pages as they load, so when the user goes back to these pages we can simply use that cached data on the frontend. This would result in a better user experience and also optimize data fetching action on the frontend.
Enter NgRX
We needed a mechanism to store some page-specific data in a global data store. This global data store would have its own lifecycle that’s not linked to the lifecycle of any page whatsoever. This means we could push some data into this store from any page and pull it from any other page of our application.
That’s where NgRx kicks in. It’s a library that allows you to create a global data store to manage reactive states in your Angular applications. Much similar to what Redux or Context API does in React. It also abstracts away the process of communicating with service and storing data in a state maintaining separation of concerns from an architecture standpoint.
We knew NgRx is the right tool but didn’t bias our opinion based on its mere popularity. So we also compared it with other alternatives like NgXs and Akita.
However, NgRX fits right into our use case due to its features, advantages, and architecture.
How NgRx Works
Let’s have a quick refresher on how NgRx works.
Normally, Angular components directly interact with certain services to consume data from external resources such as an API. NgRx introduces another layer of abstraction called effects. These effects are responsible for interacting with your Angular services whilst isolating them from your app’s components.
Think of it this way, if you’re sharing data across several components through a single service, that service isn’t component-specific anymore. Effects basically govern which services need to be invoked and when. Effects aren’t compulsory and are only required in use-cases where you want to grab data from a service.
Then, you define actions that denote events that NgRx watches or listens to in your application. The best way to understand what actions are and what they do is to compare them with custom events. When you want to share data between a parent and a child component, you emit a custom event.
Similarly, actions represent those events that are associated with some effects. These actions are then dispatched, similar to how an event is triggered.
Next, you create a reducer. A reducer is a function that listens to dispatched actions and performs state mutations accordingly. It interacts with your Global Data Store by updating the state.
Finally, in order to consume that global data in a component, you use a selector. A selector is a simple interface between your store and your components. You can define selectors on the basis of what data you want to grab from the store.
That’s how NgRx works in a nutshell. I know it’s intimidating because there are a lot of moving parts, but I bet it’d make sense when I talk about the implementation.
Key Factors in Implementation
Once you understand how NgRx works, the next step is obviously implementing it, right? Well, not really. NgRx is a big library and has different sub-packages that you will also need to install for using the store, effect, reducers, etc. Besides, there’s a learning curve to it, even for experienced developers. It’s built on top of RxJS and Observables, steeping the learning curve further if you haven’t had a chance to understand these concepts.
So first you need to evaluate your use case, where would you implement it? What would be the scope of your store?
You simply can’t dump all your data in your NgRx store. Not only you’d be overengineering your frontend, but you’d also be introducing critical bugs in your application.
So all in all, you first need to ask yourself – Where do I need NgRx in my application?
When we asked ourselves the same question, we dangled with another question. Which is the most commonly used page of our application? That leads to another question. Which pages of our application have pagination? And then another one! Which pages contribute to the most number of API calls in our application?
For us, the answer was the homepage. The homepage of MyKirana is the first page our users see and interact with. It’s also the page that has the most potential to convert new users into customers and existing customers into purchasers. This is because it’s very overwhelming with respect to data. Promotional banners, carousels, products, locations, stores, it has all sorts of data that was being pulled from multiple APIs. Also, it had pagination!
Lastly, it was also the fallback page for most of the users when they’d abandon a different page of our app. That makes sense for most of the applications as well. Where does a user land when they’re using your app but come to a point where they don’t want to go ahead further? They’d go all the way back to the homepage, or struggle to find it in your navigation menu. Especially for users who’re simply exploring your app. The root page of your app is in their muscle memory!
Implementation
Enough theory, let’s talk about some code. Here’s the step-by-step guide on how we implemented NgRx on our Homepage.
First, we defined our actions as shown below:
import { Action } from '@ngrx/store';
import { Page1, Pagination } from '../interfaces';
export enum HomepageActionTypes {
LoadHome = '[Home] Load HomepageData',
LoadHomeSuccess = '[Home] Load HomepageData Success',
LoadHomeFailure = '[Home] Load HomepageData Failure',
}
export class LoadHomepage implements Action {
readonly type = HomepageActionTypes.LoadHome;
constructor( … ) { }
}
export class LoadHomepageSuccess implements Action {
readonly type = HomepageActionTypes.LoadHomeSuccess;
constructor( … ) { }
}
export class LoadHomepageFailure implements Action {
readonly type = HomepageActionTypes.LoadHomeFailure;
constructor(public payload: { error: string }) { }
}
export type HomeActions = LoadHomepage | LoadHomepageSuccess | LoadHomepageFailure;
When the LoadHomepage action is dispatched, we call an effect that invokes the relevant service.
So in the next step, we created our effects. This effect would talk to our Homepage Service, the service that gets the data for our homepage.
import { Injectable } from '@angular/core';
import { Actions, Effect, createEffect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { Action, select, Store } from '@ngrx/store';
import { mergeMap, map, catchError, withLatestFrom, switchMap, flatMap, filter } from 'rxjs/operators';
import { getUsers } from '../selectors/user.selectors';
import { HomeService } from 'src/app/shared/services/home.service';
@Injectable()
export class UserEffects {
constructor(
private actions$: Actions,
private userService: UserService,
private store: Store,
private homeservice: HomeService,
) {
}
@Effect()
loadHome$ = this.actions$.pipe(
ofType(HomeActions.HomepageActionTypes.LoadHome),
withLatestFrom(this.store.select(getUsers)),
mergeMap((action:any) => {
return this.homeservice.getUsers( … ).pipe(
map(users => (new HomeActions.LoadHomepageSuccess({ … }))),
catchError(err => of(
new HomeActions.LoadHomepageFailure({ error: err })))
)
}
)
)
}
We define our effect using the @Effect decorator. Inside this effect, we invoke our service, structure the data into the desired format and dispatch relevant action on success and failures. Inside the action, we also pass the payload data that the reducer can tap into and push to the global state.
Actions basically help you transport data to a reducer. In our case, since we’re getting this data from a service, we’re getting that data from an effect and transporting it to a reducer via an action. However, as I have mentioned earlier, effects aren’t compulsory. Think about cases where you need your actions to transport reactive data from the frontend to the reducer without any backend interaction whatsoever.
If the effect returns success from the service, the LoadHomepageSuccess action is dispatched. Otherwise, the LoadHomepageFailure action is dispatched.
Next, we created our reducer that would listen to the above-dispatched actions and perform state mutation. It would update our global store with the data we want to display on the homepage. We have also defined our global state here as an object with properties where we can store data pertaining to different pages.
import { Action } from '@ngrx/store';
import { Page1, Pagination } from '../interfaces';
import { HomeActions, HomepageActionTypes } from '../actions/home.actions';
export const userFeatureKey = 'usersState';
export interface State {
page1: Page1[],
pagination: number,
error: string,
}
export const initialState: State = {
page1: [],
pagination: 1,
error: '',
};
export function reducer(state = initialState, action: UserActions): State {
switch (action.type) {
case HomepageActionTypes.LoadHome:
return {
...state
}
case HomepageActionTypes.LoadHomeSuccess:
var oldpage = {...state}
return {
...state,
//State Mutation based on Payload received
error: ''
}
case HomepageActionTypes.LoadHomeFailure:
return {
...state,
page1: [],
error: action.payload.error
}
default:
return state;
}
}
We simply filter through our actions and mutate the state accordingly.
The final step for NgRx implementation is defining selectors. Here’s what that looks like:
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { State } from '../reducers/user.reducer'
const getHomeFeatureState = createFeatureSelector<State>('homeState');
export const getHomeData = createSelector(
getHomeFeatureState,
state => state.page1
)
export const getPagination = createSelector(
getHomeFeatureState,
state => state.pagination
)
export const getError = createSelector(
getHomeFeatureState,
state => state.error
)
These selectors are like services that we can subscribe to from a component and get the desired data. They filter a certain property from our state and return it back to the caller.
All set! Now we go back to our Homepage component and subscribe to our selector:
this.store.pipe(select(fromHome.getPagination)).subscribe(
data => {
this.page = data;
}
)
We get the desired data from a relevant selector and store it inside our component’s state. We do this inside our component’s ngOnInit() lifecycle method.
Caveats with NgRx
Here’s what most developers miss when dealing with NgRx. NgRx has a completely different lifecycle than any of the pages or components of your app. This means that even if a page that uses NgRx store via a selector unmounts from the DOM, NgRx still runs in the background. That means your reducers are still listening to actions being dispatched and your effects are still ready to be fired.
Multiple API Calls
This could lead to multiple API calls because you did not set a rule to stop subscribing to your selector. That defeats the whole point of using NgRx in the first place! You set out to optimize frontend API calls but ended up making it worse.
So here’s what you do, you store the result of your selector subscription in a local state variable:
this.homeSubscription= this.store.pipe(select(fromHome.getPagination)).subscribe(
data => {
this.page = data;
}
)
And then unsubscribe to it when your page unmounts from the DOM:
ngOnDestroy(){
this.homeSubscription.unSubscribe()
}
Unintentional Data Caching
There could be other cases where you need to unsubscribe from your selectors other than unmounting of pages. For instance, if you’re storing session-specific data in your global state, it needs to be cleared when you log out the user.
If you forget to do that, there could be data inconsistencies in your application. These are use cases you need to think of when you’re deciding the scope of your implementation.
In those cases, you need to explicitly fire relevant API calls because the cached data is invalid for the given scenario.
However, instead of handling this case manually, you can create an action that when dispatched would empty the data associated with that page in the global state. So now NgRx would see empty data in the store and make fresh API calls for the same.
Browser Reload
At the end of the day, NgRx is just some JavaScript magic that runs on your browser. It hooks into your Angular app and whenever you do a hard reload via the browser, your Angular bundle is requested from the server.
So developers or stakeholders often assume that data would persist on the browser even when you reload the page. That’s not the case however if you want to implement a more fool-proof cache, you should look at server-side implementations like Redis.
What We Gained from NgRx
Here’s how NgRx benefited our developers, users as well as servers:
Easy Data Sharing
There are loads of ways to share data across different components in a component tree. However, as your app’s component tree becomes complex, it’s cumbersome to share data via custom events.
NgRx was super helpful in keeping common data like username, email, JWTs, etc handy in a single source of truth. Our different pages, services, and components could easily grab this data from our NgRx store wherever and whenever needed.
Enhanced UX
Our users no longer need to see those loading screens when they visit the pages they’ve already visited before. This provides a more intuitive user experience when you navigate to and fro through different pages of your app.
Optimized API Calls
Your server and your user’s network deserve some slack. Using NgRx we minimized unnecessary API calls on the most commonly visited pages. So the traffic stays intact as well as our server resources!
Abstracted Architecture
NgRx gave us another abstraction away from our services and components. That’s healthy for our codebase in terms of modularity and scalability. Also, it’s easier to debug if things go south.
The Way Forward
Once we introduced NgRx to our homepage, we didn’t want to look back. So we iteratively implemented it on other pages as well. The process, however, was the same. Evaluating use cases and scoping first, implementation second.
For perspective, we further implemented NgRx on the offers page, the use-case that I set out at the beginning of the post. Next, we implemented it on the categories page and refer and earn page.
We’re still working on evaluating where and how we can use NgRx on other pages of our application. We’ve also started using it in other Angular projects here at Shiprocket.