5 Tips to improve User Experience of your Angular app with NgRx

Alex Okrushko
Dec 17, 2018 · 11 min read
Photo by Ban Yido on Unsplash

AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!

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:

Basic API call with error handling

⚠️ 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 Success)
  • Action to reflex the error response (typically suffixed with Error)

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.

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.

no cache (left) vs cache (right) when navigating to the previous page

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:

no cache (left) vs cache (right) when navigating to the “product details” page

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 productService.getProduct(id) .

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.

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.

fresh load (left) vs hydrating data from localStorage (right) when navigating to the app

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:

In the productSync meta-reducer we check for two special Actions that NgRx dispatches itself:

  • INIT — dispatched when Store is initialized (including any forFeature pieces 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).

Optimistic updates/deletes

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.

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.

Waiting for the server response (left) vs “optimistic” update (right) when adding the item to the cart

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.

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 ShowAddCommentDialog Action.

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 AddComment or AddCommentDialogCancelled correspondingly.

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 AddCommentError payload.

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.

Summary

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!

Angular In Depth

The place where advanced Angular concepts are explained