"Please do not close or leave this page" may send shivers down your spine, but coding the proper UX flow for async might make you question your daily job. How can we properly handle UX for asynchronous code in highly responsive applications? Let's explore how introducing asynchronous code creates a challenge for UX.
AI Generated Video Summary
Today's Talk covers the importance of building Asynchronous UX with React and single-page applications, providing code and UX examples. It explores data fetching, adding progress indicators, handling errors, and user-initiated actions. The Talk also discusses handling component unmounts, multiple actions, idempotency, and context loss. Finally, it touches on considerations for optimistic updates and the use of CRDT or other technologies for collaborative applications.
1. Introduction to Asynchronous UX
Today, I'll be talking about Asynchronous UX with React and single-page applications. I'll cover scenarios you'll encounter in your daily job and provide code examples and UX examples. The key takeaway is the importance of building this kind of UX for users and the development cost.
Hi, thank you for joining today's session. My name is Tony and today I'll be talking to you about Asynchronous UX with React and single-page applications. When we say that the web is asynchronous, we are referring to the underlying request-response nature of the web, where a response from the server might take some time to arrive. Traditionally, applications built with server-side rendering in mind were handled by the browser. When we build the same application as a single-page application, we have to build new affordances for the user because the browser can't handle them for us. In today's session, I'm going to go over certain scenarios you will encounter in your daily job and try to address them with code examples and UX examples. At the end of the talk, I hope you'll take away from it that it's very important to build this kind of UX for the users and how much it will cost in terms of development.
2. Exploring Data Fetching and UX
Before we begin, let's divide this topic into two areas: read-only data patches and user-initiated actions that change the server state. When navigating the page, fetching data may take some time. To improve the UX, we can add a progress bar or employ skeleton UX. Building more consistent UX can be complex, especially when multiple components have their own proxy indicators. To avoid flickering effects, we can introduce a delay before showing the progress indicator. Let's look at a naive example of code for a page and explore how it can be improved.
Before we begin, I just want to divide this whole topic in two different areas. The first area deals with read-only data patches, and the second area is user-initiated actions that change the state of the server, basically doing form submissions while the queries are page navigations.
Let's talk about queries. So whenever you navigate the page, you might fetch some data. In this naive example, when we navigate to a page, we will fetch the data and then display it to the user. This is a happy scenario where the data arrives somewhat quickly, but depending on the network load, the server load, or the user data, waiting for the data to arrive might take some time. While the user is waiting, we kind of want to inform them that they have to wait in case there are no results. We want to tell them there are no results. If there's an error, we want to tell them that there was an error and what they can do about it.
To improve this UX a bit, we want to add a simple progress bar. This progress is indeterminate because we don't know when it will end, and the user might just want to stick around and wait for the data to arrive. To improve on this very simple progress indicator, we might want to employ skeleton UX. Now skeleton UX looks like the data is going to replace. Basically, this stops flickering effect from happening, because the users will see the outline of the incoming data, and then when the data arrives, it basically just replaces hopefully in place. Unlike progress indicator, skeleton UX is much, much harder to build and can quickly go out of sync with the components they're trying to mimic. So be wary of the complexities involved in building them.
For a bit more complicated page, you can see that when the data is there, you might want to have partial updates with fetching more data. You might want to have different ways of fetching data which might yield different data sizes. So in this case, we can see that sometimes you get little data, sometimes you get no data, sometimes you get an error. And when you have data fetching, you kind of want to reuse this UX. As you can see, building more consistent UX requires some explaining, and you can imagine how much more complicated the code might be. When we build components in our applications, we might want to focus too much on a single component, so each component has its own data fetching and its own proxy indicator. What can happen is that we can quickly end up in a situation where multiple components on the same screen have their own proxy indicators, and then we have this flickering effect as different components have data arriving in a different order. Also, when we have very fast servers, it can be annoying because of the flickering effect because data will arrive very quickly and then the users will see the progress indicator for just a brief time. So to improve that experience, we might want to introduce some kind of delay before showing the progress indicator, so only showing the progress indicator when the data takes a long time to arrive.
Let's take a look at a very naive example of how the code would look for a page. Regardless of whether or not you use React query or Apollo or something similar, your component might look like something like this – you fetch data and you display it inside your UI. Now, can we do better than this? Of course we can, but it will require additional work and it will require a different type of work. So let's take a look at how much more complicated the code can get. On the left side, we can see the naive example from before, and on the right side we'll keep improving it by handling different scenarios that we've shown in the previous demos.
3. Adding Progress Indicators and Handling Errors
The first thing to add is the progress indicator. It can be implemented as a top-level loading indicator or inline below the H1. Handling errors, empty states, and skeleton loaders can greatly improve the UX. Introducing a delay and preventing the display of multiple progress indicators are important. Handling backend errors depends on the application, including user fixable errors, server load issues, general failures, and network connection problems. Improving the UX for long running responses is also crucial.
So the first thing we want to add is the progress indicator. Now, that is kind of straightforward, we just have some kind of flag saying that, yeah, loading is being done, in which case we show the loading UI. Now, you can already see that this can be done in two different ways, either as a top-level loading indicator or you can actually inline below the H1 and thus have a bit more intrusive loading indicator. Your opinions influence the way you implement this. If we want to handle errors, we have to add yet additional code, and you can see that this displaying of the error might also change location and be a bit more intrusive, thus making this pattern harder to extract in a higher-order component.
You can also handle, for example, the empty state, which, as you can see, it goes a bit more intrusive in the overall structure of the page. So as you can see, adding support for many UX scenarios will have a different impact on your code. And sometimes it will be hard to squint and see how we can actually isolate this in a higher-order component that can be reusable across the application. So there is always more that we can add to the page that can improve the UX. So empty states generally improve situations where the application is brand new for the user, basically they have no data, or when they are, for example, searching for stuff that doesn't exist. So in this case, we want to actually always show the results to the user, or tell them that there are no results. That improves the UX tremendously.
Skeleton loaders are a much better UX over the regular, simple progress indicators, but they require a bit more development time, they can quickly go out of sync with a target UI that they will replace. So take special care about introducing skeleton UIs because of the complexities they bring. Progress indicators, again, just a naive boolean, don't show, might not be enough. So we want to introduce some kind of delay to prevent the flickering effect. And also we want to prevent this forest of progress indicators, and only keep the indicators for something that's really, really slow. We haven't really talked much about how to handle backend errors, because it depends on the application. There are different kinds of errors. There are the user fixable errors, basically they can do something else. There is the retry later because the server has a tremendous load. So we can't really handle the request right now. Or there can be a general failure where we redirect the user to use the support, and basically asks us what went wrong. There's also a special case about network connection issues. For example, if you're in high density urban area or going out of reach, like in a tunnel or just in a rural part of the country, you might actually drop connection or have this intermittent connection issues. In that case the UI must or should handle these cases by showing to the user that right now data cannot be fetched and they should either refresh the full page or display a button where they can retry the fetch themselves when the connection recovers. You also might want to improve the UX for the long running responses. Sometimes waiting for more than five seconds will just look bad for the user. They will not know what to do. They might want to refresh the whole page.
4. Handling User-Initiated Actions and Side Effects
They don't know what went wrong. In the side effectful area, we want to handle user-initiated actions and inform the user about the action's progress and any issues. We also need to consider side effects in the application. Let's look at a demo of a responsive UI where a button click triggers an action and provides feedback. However, double clicking can cause issues, and we need to handle them properly.
They don't know what went wrong. So you might want to actually change the text message after a certain while or do different kinds of improvements.
If we take a look at the second area, the side effectful area, we want to see how to handle the user initiated actions. Now, because the user initiated an action, unlike navigating, they want to know that the action is being handled. They want to know if the action has been done handling, are there any issues. In case of an issue, is there anything the user can do or is just a general failure.
We also want to take a look are there any side effects in that application because depending on what the user did, it can change. So let's again take a look at some demos. In this case, we're going to take a look at a very simple attempt at building a responsive UI. So when you click a button, after a while you get a success message saying the action has been completed and the user can continue doing whatever they were doing. However, you can see that this is a really sad experience because the user can double click and then we don't know what the server side can do in that case. We might create double entries or something similar.
5. Handling Errors and Idempotency
In the case of an error, you never know what the error action is. A well-behaving button disables itself to prevent double submission, displays a progress indicator, and informs the user when the action is done. To improve user experience, we can implement an idempotency key to prevent duplicate actions. Single-page applications may cause side effects if the user navigates away before an action completes.
In the case of an error, you never know what the error action is. Did we misconfigure click handler? Was a network drop? Did the server respond with a 500? We don't know. And we want to inform our users that there's been something wrong. A well-behaving button, in this case, disables itself so that it prevents double submission. It also displays a progress indicator. And also when it's done, it informs us that something has happened. This is the example we want to strive for.
Some applications, when you're doing very advanced things, might want to tell you that please don't leave this page. Please wait until the operation is done. This might take some time. You never know how much. In a happy pet scenario, this will take a second, and you're done. However, if this page stays on and on, the user might be confused or worried. And they're not really sure if they should leave the page, what happens if they refresh. And that can yield subpar user experiences.
Now, because this is an example of booking tickets known to everyone, we want to see what we can do to improve the user experience. So let's take a look at this button. Let's say it takes a long time to execute and during this time something's happening in the server. After a while, the result comes back and we book it. Now I'm going to do something different. I'm going to click on a button and refresh the page. This is just basically destroying the context. When we go back to the page and we click the same button, it's done almost immediately because the server-side response, yes, the operation you requested was actually done before and this is the result I would have sent the previous time.
How would we do something like this? The idea is very simple, a bit more hard to execute for such important actions that really should not happen twice. We implement something called idempotency key. Basically, we generate a random key, assign it to the action and then we remember this key in local storage and when sending the request to the server, we send the same key. On subsequent requests, the server will duplicate the request and actually respond with the previous response, thus not doing the same operation twice. This is very important if you want to handle, for example, payments or any kind of limited resources in the real world because you don't want to do it twice. You don't want to order two things or pay for the same thing twice.
Now, because single-page applications are not built the same way as server-side renders, what can happen is that when you perform an action on one screen and if it takes too long the user can leave and go to another page and then when the action completes, the response will come and side effects might happen that will potentially cause bugs.
6. Handling Component Unmounts and Side Effects
When we click on a button and then leave to a different page, we may still see the same alert. However, if we try to modify React UI, we'll get a warning about a memory leak. To handle this, we need to remember when a component unmounts and avoid performing side effects.
So, let's take a look at this example. When we click on this button, an action done will be said that this was completed. If we click and then leave to a different page because nothing prevents us from leaving, we can still see that we have the same alert. Now, this is just illustrative. If we try to do something like set state or try to modify React UI, we'll actually get the warning in the console saying that we have a memory leak. We should not do anything because the component has been unmounted. To properly handle this, we need to kind of remember that when component unmounts, we actually wanna know that the component was unmounted and actually not perform the side effects. This is just illustrative saying that, yes, we understand this component knows it's been unmounted and thus, it should not actually perform the side effects.
7. Handling Multiple Actions and UX
One final thing to consider in rich client-side applications is the handling of multiple actions on the same screen. It's important to ignore previous requests and only take into account the last sent request. Adding code to improve the user experience can become intrusive, especially when handling errors. Field level validation, blocking input while waiting, and handling network issues are important considerations. Blocking actions prevent users from leaving, while non-blocking actions display toasts when complete. Having a notification center and preventing double submissions are also good practices.
One final thing that can happen with a very rich client-side applications is that when you have a series of actions on the same screen, which render in the same UI but might take a different time to respond from the server. For example, filtering data might have different response times depending on how much data needs to be processed on the server. If the user clicks around many different times, you can see that the responses come out of we don't show the last clicked action result, we actually show the last result that arrived, which might actually be the one that was sent earlier. So special care must be taken to basically ignore all previous requests and only take into account the last sent request.
Let's take a look at some of the code. So on the left side I'm going to keep the very simple, very direct implementation of a handling submission. And on the right side I'm going to add more code to improve the UX and we'll see how much bigger the code becomes and how much more intrusive this UX becomes. So the first thing, we're going to add a state for whether or not we're submitting or not. As you can see, we need to state variable, we need to properly handle it when it starts, when it stops. We need to display the UI that shows that the action is being performed. And then we also want to disable the button. If we want to handle errors it gets even more intrusive because we are touching already touched code and you can see that the UI is also being changed in a very inline way. It's really hard to isolate this in a higher level component.
And as before, there is always more we can add. We can add field level validation where we actually tell the user that some fields were wrong, they can fill them. Or we can tell them that, no, there is something you did wrong, it's the server side error. We can block the input while we wait because we want to prevent them from editing the fields. We want to handle network issues. This is very important in case there is a lot of work done. And if they kind of lost the work, it will be bad for the user. We might save the values. If it's a model, we might want to consider while we're waiting for response, do we allow for leaving and lots of other things. We generally want to think about blocking versus non-blocking actions. Blockings are the actions that prevent you from leaving. Because your actions after this one depend on this one succeeding. There are also non-blocking actions, also called fire and forget. In those cases, we just want to display toasts when the action is complete. Always consider having a notification center or something where the user can look up the previously done actions. In general, we want to prevent double submissions. The simple solution is just to disable the action buttons.
8. Handling Idempotency and Context Loss
We want to use idempotency for important scenarios like booking or payments. Handling network issues involves retrying failed requests and informing the user through email or SMS. Idempotency allows safe retry of actions. Context loss occurs when navigating through the application, and we need to handle unmounting components and race conditions to prevent strange UI rendering.
Basically, we want to click it twice. However, that's not always good. If the page is refreshed, the button may be clicked again. The advanced solution is to use idempotency. We really want to use them for really important scenarios, such as booking or payments. They require additional work on the server side, but they will yield the best response for the user and for the UX.
Network issues, as mentioned earlier, we want to handle network issues in two different ways. There might be the outgoing issue, basically, we were unable to send the request to the server, thus failing, so they can retry again. Or actually, we send the request, however, while the server got the request, while the response was traveling back or while we were waiting, in the meantime, the network had some glitches. In that case, the server still processes our request, and the user can actually retry again and thus go into inconsistent state, basically doing the same action twice. We want to see if we can inform the user some other way, like using email notification center or SMS, that the action has been completed. As before, idempotency on the actions allows for safe retry, because you can actually send the same request twice, and the server will not do the same work twice. This requires idempotency, it cannot be done just by analyzing the request, because sometimes it's valid to do the same thing with the same data.
The context loss is the situation where we can navigate through the application, and then when the operation succeeds, something bad might happen. I have some links here you want to take into account when researching this topic. Let's take a look at a simple way of handling whether or not the component is mounted or not. This is a very simple hook that will tell you whether or not the component has been unmounted. We can use it in a very straightforward way by just basically checking if the component has been unmounted. This is a click handler. If we have useEffect, it becomes a little bit trickier because we can do it in three different ways. We can either split the work in the available part, and then make it cancelable, and then handle it in cache, basically separately. As you can see, the logic actually kind of gets split and this is not a really great solution. This can be done and we use the unsubscribed method to stop the asynchronous action. Another way is to use, again, isMounted the hook to know whether or not the component has been unmounted, basically return from the function import subsequent processing. The third way is to use something that's called cooperative cancellation, inspired by C-sharp. This code displays how one would actually create a token in the effect itself and then cancel it in the unsubscribed part. The asynchronous function after every await call must check if the cancellation is requested. Basically after await call, the component might be unmounted and we don't know so we have to check. Another thing we want to handle is the race conditions as shown in the demo. If we reuse the UI after actions, depending on the order of them arriving, this might render a very strange UI.
9. Considerations for Optimistic Updates
One final thing to consider is optimistic updates, where we pretend that an action succeeded immediately to make the UI responsive. However, this can lead to inconsistencies if subsequent actions encounter errors. In such cases, consider CRDT or other technologies for collaborative applications.
One final thing before I wrap this up is the optimistic updates. It's very common these days to have a client side caching and basically when initiating an action we actually pretend that the action succeeded immediately so that the UI looks very responsive and then later on we basically synchronize with the server. Please be advised that this might lead into very bad situations if there are subsequent actions that the user takes, because if there are network or any kind of other errors. If you don't handle it correctly in the UI, it can get pretty inconsistent pretty quickly. In that case, consider CRDT or other technologies if you're building for example collaborative applications.