5 Tips to improve User Experience of your Angular app with NgRx
Web app performance matters. It matters even more so when the company’s finances are depending on such apps for users to engage with the site and to keep them coming back. As numerous studies have shown, poor loading times and overall slowness leads to the increased bounce rates and decreases the general satisfaction of the users.
It’s already well known that Angular’s
OnPushChangeDetectionStrategy can measurably improve app performance and that NgRx’s immutable state works really well with it. So, in this article, I’m going to show you how NgRx can help to improve User Experience with the main focus on slowness and errors introduced by API calls. In particular:
- Error handling
- Providing cached temporary data (between pages/when returning to the app)
- Optimistic updates/deletes (interacting with buttons and dialogs/forms)
Tip #1. Error handling
How many times have you experienced the “loading” spinner, when you submitted a form or navigated somewhere within the page, only to find out that this spinner is not going anywhere❓ You open up the console, and what you see is the error log of a request that simply failed. We, as developers, frequently forget to handle such error cases — they are not in our immediate/happy path of feature development and frequently become an afterthought.
NgRx is also not immune from unhandled errors; however, it has a number of established patterns that help battle some of these error-prone scenarios. Moreover, since all APIs calls are done in Effects, the cost of an unhandled error is higher — the effects would stop listening for any further Actions.
Here is the example of the basic API call within Effect:
⚠️ Not only is the
catchError operator part of this Effect, but its placement is also very important — it should go within the
switchMap pipe (or another *Map flattening operator) and that has little to do with NgRx itself; if placed outside of the flattening operator, this is how RxJS works: once the error is produced,
catchError handles it and closes the stream, which means that the Effect will dies silently and will not handle any further Actions as well.
Any Effect has to produce the
Action unless explicitly told not to. That means that even
catchError has to result in the
Action. So that brings us to the pattern where three Actions are required for any API call:
- Action to trigger the Effect
- Action to wrap the successful result (typically suffixed with
- Action to reflex the error response (typically suffixed with
This pattern will also become handy in some of the further improvements that I describe in this article ⬇️.
Providing cached data
There are two distinct use cases here. The first one is to provide the data when the user is navigating between the pages, and the second one is when the user returns to the app. Let’s look at both of them in detail.
Tip #2. Store as a cache
Let’s use the “store” application as our example. It has three pages: the “home” page with the list of products, the “product details” page with more details about the specific product and the button to add it to the cart, and the “cart” page, that provides a summary of all the products added to the cart. Most of the examples provided here are taking a closer look at the “home” and “product details” pages and their interactions.
Consider the following scenario: the user opens the “home” page which calls the API to Fetch the list of products. Then the user clicks on one of the products and lands on the “product details” page. Sometime later they click back to return to the products list.
Below are the two side-by-side animations of that last step showing different implementations: the one on the right uses NgRx store as a cache, displaying the previous list of products while we are waiting for the new fetch API call to complete, at the same time indicating with the indeterminate progress bar that the data might still change.
Such an approach provides instant feedback to the user and feels like the app is very fast and responsive.
⚠️ However, it should be used with care and assessed if it’s OK to show possibly stale data (while waiting for the new stuff). For example, this won’t work well if that data changes frequently — think of the Facebook or Twitter post feeds. In those cases, ghost elements with animations would be a better choice.
One might argue that you don’t really need NgRx to achieve this improvement — and indeed a stateful service can pull it off just as well. However, we are then getting back into the situation where the application state is spread across many such stateful services. This is exactly what we were trying to avoid to begin with. Stateless services are much more pleasant to work with.
Another scenario that would be even harder to achieve with just services, is the following: the user opens the same “home” page with the list of products, then clicks on one of them. The response from the
FetchProducts API call only carries the minimal information needed for the “home” page to be displayed. Each product info doesn’t have the “product description”, for example, to save the size of the response’s payload. The
GetProduct(id) call, on the other hand, returns all of the info about the product, including the “product description”.
Here is how it looks:
On the left side, we are waiting for the
GetProduct(id) to return the info, while on the right side we already show the partial info that we have in the Store about this product from the
FetchProducts call, and once the
GetProduct(id) returns us new data (including “product description”) we merge it.
The exciting part here is that no changes are needed for
ProductDetailsComponent. It’s just selecting data from the Store with the same selector.
The Effect that handles
FetchCurrentProduct Actions is also more or less standard: it takes the current product id (provided by the router-store) and calls
The piece that makes it all work is in the reducer. When we handle
FetchProductsSuccess (the Action produced when a response with a list of products arrives), we update the entire state that contains them. However, when
FetchProductSuccess is dispatched (single, not plural), then we update only that specific Product.
Here is the reducer that does it using ngrx/entity to wrap Products list.
Tip #3. Hydrating store state from localStorage 🚰
So it’s great to have snappy page loads when navigating between pages of the app, but you can push it even further — fast initial page load when the user navigates from another web site to your app.
In this side-by-side comparison, we see how much faster the one on the right feels. That’s because we don’t wait for
FetchProducts response to return, and instead, we show the data that we kept in the browser’s localStorage from the previous visit to the site.
There are a few cautions here:
- 🚫 Please don’t store sensitive data in localStorage
- ⚠️ The data could be very stale; carefully assess if it actually makes sense to show it
So how can we sync our data to and from localStorage❓ NgRx has a convenient way to provide middleware, that can listen for all actions/state pairs that go through our application — it’s called meta-reducers.
Here is the basic and naive implementation of how to persist the products state from/into localStore:
productSync meta-reducer we check for two special Actions that NgRx dispatches itself:
- INIT — dispatched when Store is initialized (including any
forFeaturepieces that are not lazy-loaded)
- UPDATE — dispatched when feature reducers are added/removed.
For our purposes, we can just ignore UPDATE actions and read from the localStorage on INIT, and write back on any other action. This is, of course, a very oversimplified solution and libraries such as ngrx-store-localstorage might better cover some of the edge cases (⚠️ Disclaimer: I haven’t used that library, so cannot vouch for it).
What does “optimistic” update even mean❓
When the user interacts with the UI in a way that results in an API call, we typically wait for that request to succeed before updating anything in the UI. Usually, we show a spinner so that the user is informed that something is being updated and the results are not there yet.
When the only purpose of waiting for the response is to make sure that the update is received by the server, we can take an “optimistic” approach. That would mean that we assume that the server would handle the request without an error and we update the UI right away. Should the error occur, we would roll back the update.
Tip #4. Optimistic interactions with the UI
Let’s look at one of the examples in the store app. When the user clicks to add the product to the cart, we assume that it will be successful at the backend and we push this product to the Store’s cart items list immediately. Subsequently, the cart’s item count increases as well — even before we have received the response from the server.
Notice how much more responsive the app feels with the “optimistic” updates on the right.
If the server returns an error, we remove the item back from the cart in the Store and maybe even handle it with snackbar/toast explaining what happened to the user.
The entire “magic” of the optimistic update is in the effect/reducer implementation — the rest stays the same. In this case, the
AddItem Action serves not only to trigger the effect, but also to push the Item to the list in the reducer. The
AddItemSuccess Action, on the other hand, is mostly ignored by the reducer (unless you are using
isLoading flag, then it should be flipped to false). And finally,
AddItemError Action should remove the optimistically added item from the state in the reducer. Effect usually passes the itemId in the error’s payload.
Here is how Effect looks like:
And here is the reducer, that operates with IDs (and not using @ngrx/entity)
⚠️ ️Just like with the other improvement techniques, use it with care. Before refactoring all of the code to use it, make sure that the following conditions are satisfied:
- The API has a low probability of the failure/rejection
- API is not critical to be completed before moving on
For example, “adding item to the cart” is a good candidate for such improvements. However, “completing the purchase by clicking BUY button” is not — it doesn’t meet any of the requirements listed above and if it fails you’d have to bring items back to the cart, navigate there and your “congrats! your purchase is complete” message will look really out of place. Personally, I would probably stay away from such a “store” app.
Tip #5. Optimistic updates on Dialog/Form submission
Alright, so now we have an understanding of how to do optimistic updates when the user is clicking a button. But, we can push it even further to handle Dialogs and Forms optimistically. The main difference here is that, should the request fail, your error-handling part of Effect would have to navigate/reopen the dialog. And, in addition to displaying the error message, it should set all the values that the user has previously entered.
My workshop-oriented “store” app doesn’t have any forms or dialogs, so I’ll demonstrate it with a generic dialog and effect. For example, let’s pretend that you can add a comment to the product by clicking an “Add comment” button which brings up a dialog with an input field.
The component itself is not aware of the dialog and the “Add comment” button just dispatches the
AddCommentDialog already tries to prevent sending an unnecessary request by specifying that comment is a required field. That’s a good start. However, the backend might reject the comment for various other reasons, such as “abuse” by the user (posting too many comments) or maybe the comment contains profanity.
OK, let’s look at the shape of our dialog component:
There are a few things going on. First, the comment itself is a FormControl with required validation. Secondly, the
@Optional errorPayload is injected into the dialog. If it’s provided, it will carry the
errorComment that was previously entered and the
errorMessage itself, describing why this comment wasn’t submitted.
Now let’s break apart the three Effects that work with this dialog.
The first Effect listens to the
ShowAddCommentDialog that is initially dispatched from the Dialog and it could carry the optional payload (but doesn’t when dispatched from the dialog). We also pick up the current product id (often derived from the current state of the router) along the way. Then we get to the part where we actually open the dialog and wait until it’s closed. Depending on whether a comment was entered or the “cancel” button clicked, we dispatch either
Note that even though we were waiting for the dialog to close, we were NOT waiting for an API response at all. We didn’t even send the request yet, instead, we optimistically closed the dialog. That leads us to the second Effect.
This is where the API call is actually triggered and we optimistically update the UI with the new comment, similar to how we did it in tip #4.
💭 There are multiple possible implementations of the rejection. One way (and actually the preferred way) would be to return a 200 response with the error payload from the server. The other one would be to set a non-200 response status with the error payload. For simplicity’s sake, we assume that’s the case we have here.
Note, that we pass both the error AND the comment itself to the
Finally, the third piece:
Here we transform the
AddCommentError Action back into
ShowAddCommentDialog Action, which in this case would actually have the payload with errors. When that Action is handled it would re-open the dialog and populate it with the previously entered comment along with the error message itself.
⚠️ In addition to having a low probability of error and not being a critical API that has to succeed, please consider any negative experiences this approach might introduce with the error case. If that would be too much for your users in particular cases, then don’t use this technique. Be thoughtful about it, and put yourself in your users’ shoes.
There are a number of nifty tricks on how to improve the User Experience. And in this article, we looked into the ones that make the app feel faster when API calls are involved. Be it error-handling, caching or optimistic updates/deletes — NgRx handles them really well and with minimal effort. At the same time, please be mindful of the side-effects that they could introduce.
Do you have another NgRx tip that could improve the User Experience that I didn’t mention? Or maybe you have strong opinions about the ones that I listed? Or you might have some other feedback for me? Please leave a comment bellow ⬇️ 😀
Are you interested in NgRx? Do you want to learn everything from the basics to advanced techniques?
If you are in San Francisco / Bay Area, I’ll be doing the popular 2-day NgRx workshop on October 23–24, 2019. Tickets and more info is available here: https://www.eventbrite.ca/e/ngrx-in-san-francisco-from-start-to-advanced-concepts-in-2-days-tickets-74313759455?aff=aid
Let me help you take your state management skills to the new heights! Hope to see you there!