In this talk, you will discover how to manage async operations and request cancellation implementing a maintainable and scalable API layer and enhancing it with de-coupled cancellation logic. You will also learn how to handle different API states in a clean and flexible manner.
Advanced Patterns for API Management in Large-Scale React Applications
Transcription
Hello, and welcome to Advanced Patterns for API Management in Large Scale React Applications. My name is Tomasz Findly, and I'm a full-stack web and mobile developer with 9 years of programming experience. I'm a co-owner of Findly WebTech and a mentor and consultant at Codementor.io. I'm also the author of Vue and React The Road to Enterprise books, as well as a technical writer for Telerik and The Road to Enterprise blogs. Now, let's have a look at what we are going to cover today. So first of all, we will start with how to manage API requests in React in a scalable and flexible manner with an API layer. Then we will see how to handle different API states while performing API requests. We'll also create custom hooks to manage API requests and states, as well as how to cancel requests with Axios and an API layer. Finally, we'll have a look at how to use React Query and API layer and how to cancel requests with them. Okay, so how can we perform API requests in React? Actually, it's quite simple, right? We can, for instance, use Axios for that. We can just import it and use it in our components. And well, it can work fine for, let's say, small applications. But there are some issues as the project grows, especially large ones, really. So what are the main problems with this approach? First of all, code duplication and lack of reusability. Because imagine that we need to, for instance, fetch information about a user, let's say on the login page, register page, and the user profile page. So all of this would have the same code snippet. So Axios gets a URL endpoint and so on and so on, right? So that's not really reusable. And also, it's hard to maintain because if we had to make any changes to this code, like let's say, for instance, the URL endpoint change, or we had to change the payload format, or let's say we had to migrate from using Axios to something like Firebase, right? We would need to visit every single component that is Axios and change them. Well, that's not really great, to be honest. So let's have a look at how we can fix these issues by implementing an API layer. So first, we would start with the base API file, where we would import Axios and create a new instance with some default configuration, like, for instance, a base URL. Then we'd have an API function that basically returns API wrapper methods around our HTTP client. In this case, Axios. And finally, we export it. Next, what we can do is we can use this base API file and import it in another API file. So for example, in this case, we'd have users API file, because we want to do some things with users like fetch information about users, add a new user, and so on. In this example, we have three methods, list users, get user, and add user. And as you can see, they all use the API methods from the base API file. And how would we use these in a component now? So basically, what we would do is we would import an API method from the API directory, and then just use it in a component. So as you can see, the component doesn't have to think about what kind of API endpoint has to be hit, right? The API method takes care of that. So what about if we wanted to use a different HTTP client? Like let's say, for instance, Firebase instead of Axios. So our base API file would look a bit different. So here we would just import some necessary Firebase methods, would initialize the Firebase app, and then we would export what we need. So for instance, Firebase, Filestore, which gives us methods to basically, well, connecting with the database, and so on, and other things like off storage functions, etc. Now, in the users API file, we would again have the same methods like list users, get user, and add user. However, in this case, we're using Firebase, of course, right under the hood. But let's have a look at how it would look like from the components perspective now. Well, actually, for the component, nothing would change, because it still would import the API method from the users API file, and it would just execute it, right? So that's a really great thing about the API layer, because it's like a black box to your components, right? Because your components don't need to yet don't really have to care about what you use to perform requests, what you really want, you know, it only is concerned with what methods it should call, what kind of input it should provide, and what kind of output it can expect. As long as you can preserve this input and output contract, you don't need to make any changes to your components. You only need to make changes to the API layer, really. Now let's have a look at the benefits of the API layer. First of all, maintainability, as all the API related code is in one place, scalability, because you can easily add new API methods and files. And we also have flexibility, but it's much easier to replace the HTTP client, and also enhance the API layer with custom logic. So as you've just seen, we replaced access with Firebase, and we didn't need to make any changes to the component. And also code reusability, because API methods can be just imported and used anywhere in the application. And well, API layer pattern is also framework agnostic. OK, so next, let's have a look at how we can handle API states. So what I've seen in a lot of applications is basically using Boolean flags, right? For instance, if you want to show a spinner, you would have isLoading state. If you want to show an error, if for instance, a request failed, you would have an isError flag. And let's say if we want to lazily initialize a request, for instance, if a user clicks a button, or if a user scrolls to a certain element, then we can also have this initialized flag, right? So as you can see here, we have three different states, and then all of them are updated accordingly. The problem, however, and here, as you can see, if it wasn't initialized, we display a button. If it's loading, we display a loading data message, can display a spinner. If there was an error, then there was a problem. And finally, if everything went all right, we are displaying the data. But yeah, the problem, however, is that for each API state, we need a new state hook, right? We have initialized, isLoading, isError. So that's already three states. That's only for one API request. If we need to make two requests, we might need to have six states. If we need to make three requests, we might need nine, and so on and so on. But that's not really great. So let's have a look at how we can improve it by using API states instead. And we will also implement a useAPIStateUse hook to help us with that. So first of all, we have four different API states. Idle, which means that basically the request didn't start yet. Then we have pending, which means that the request is being performed. And then we also have success and error. So obviously, success is for when the request is completed successfully, and error if there was a problem. We also have an array here, which basically just exports the constants, as we will need it in a moment. So let's go now to useAPIStateUse hook. So first of all, we need to import useState and useMemo. And we also get the idle constant, as that's our initial state for the hook. And we also get default statuses. And the reason why we need them is because we want to basically take all our statuses and then basically return an object with basically the statuses, like isIdle, isPending, isSuccess, isError, as you can see on the right side on the slide. And only one of them will be active at the time. So for instance, at the start, only isIdle will be set to true. And now here's our useAPIStateUse hook. So we have our state for it. So in comparison to boolean flags, we have only one state that holds all the API statuses. And only one of them can be active at the same time. Then we have the result of prepareStateUses. As I mentioned, basically it's an object that has isIdle, isPending, and so on. And we use useMemo here so that it only re-evaluates if the API status changes. And then finally, we return an object with setAPIStateUses method and all the normalized statuses. Now, let's have a look at how we can use it. So as you can see here, we basically initialize the useAPIStateUse hook. And we can pass the idle there, though it's the default state anyway. If you wanted to start with pending immediately, you can do that. And then we destructure all the statuses as well as the setAPIStateUse method, which then is used to basically update this API status accordingly. Before the request is started, we set it to pending. If it's completed successfully, we set it to success. If there's a problem, we set it to an error. And as you can see in the markup, we can basically just add ternaries, right? If it's idle, then we do something. If it's pending, then we show the spinner or loading message. If it's error, then an error, and so on and so on. So it's much cleaner this way. You don't need to have as many boolean flags. And the code is much cleaner and more concise. So now let's have a look at how we can improve it even further by implementing a custom useAPIHook. So first of all, we import useState again, as well as the useAPIStateUse hook that we've just covered, and three API statuses, pending, success, and error. Then useAPI accepts two parameters. The first one is the method that will execute an API request, and the second one is a config object. So for instance, in this example, we can pass initial data inside of the config object and set it on the state for the data. Besides the data state, we also have a state for the error. And of course, we initialize the useAPIStateUse hook. Now next, inside of useAPIHook, we have the exec function. So as you can see, what it does, it takes care of basically setting API statuses and updating them accordingly, depending on the status of the request. It also takes care of setting the data after the request finished successfully. And it will also handle setting the error if there was a problem. And it will clear it out if a request is supposed to be started again. And finally, the exec method returns an object with data and error if we needed to handle them. Okay, and last but not least, just return everything from the useAPIHook data, APIStateUse error exec, and so on. And now how can we use this hook? So obviously, we need to import it and then just pass the method that is supposed to execute the API request. So in this case, we're passing a method that will just return the result of getUser, which obviously comes from the API layer. And from the useAPI, we receive an object from which we destructure the normal statuses, the data, and the exec method. And as you can see in the JSX, again, depending on the status, we just return appropriate markup. So, you know, for isIdle, the button to initialize fetching, for spending the spinner, and so on and so on. Okay, so how can we cancel requests when using Axios with the API layer? So actually, we can enhance the API layer with abort logic. So in the base API file, we would add the withAbort method that would first accept the function. So this function would be one of Axios methods, like Axios.get, Axios.put, Axios.post, Axios.pat, and so on. Then withAbort returns a function, which, well, basically, these arguments should be the ones that are passed to Axios methods. So for instance, URL, body, and config. Then what we're doing there is we need to get access to the original config. And the reason for it is because as part of this config, we want to pass an abort property, which should have a function as a value. And what we're doing here, if abort is a function indeed, then we create a new cancel method and a cancel token using Axios source methods. And on the config object, we assign this cancel token, and finally, we execute the abort method and pass the canceler there. You will see why we did that in a moment. And finally, after enhancing the config object, we just execute the request. So we forward all the parameters besides the last one. And the reason for it is because we don't want to pass the original config with the abort property. But rather, we want to pass our own enhanced config object that doesn't have the abort property, but might have a cancel token. And note that it's important that we use await here, because if there was an error, basically without await, this error wouldn't be caught in the context of with abort. So that's why we need await here. And then finally, if there's an error, we use Axios's isCancel method to check if the request was canceled, and if it was, we basically set an aborted property on the error object, and we just throw the error feather so it can be handled outside. So let's, okay, one more thing. We also need to wrap, of course, our API wrapper methods with abort. So like I mentioned before, we first pass Axios methods, and then we initialize it again and forward all the parameters. Okay, so here's how we can use it. Now, so imagine basically a feature like, you know, a search box. For instance, a user can enter some query, and there will be an API request made to fetch some information, for instance, for autocomplete. So we have two states, one for the data that will be fetched from the API, and one for the query that the user enters. And we also have the search abort ref. So that's an important part here. The thing is that we need to be able to store the canceler method in between re-renders, right? So that's why we will put it on a ref. But first, let's just have a look at the onQueryChange function. So what's happening here? Well, obviously, we are setting the input value on the query, but that's not the important part here, really. But here, before the request is initialized, we are trying to cancel the previous one. However, there might be no canceler method when the onQueryChange is initialized for the first time. So that's why we use optional chaining operator. So basically, if there's a canceler method, execute it. But if it's not, then that's fine. We don't care, but we don't want the JavaScript engine to throw an error here, right? That's why we use the optional chaining operator. And then next, as a part here, the abort property. So basically, as part of the config object, we pass this abort property that receives a function, and the canceler method is passed as the first argument. And then we can basically assign this canceler on the ref, so that when this onQueryChange is initialized again, we will have access to this canceler. And finally, in the catch statement, we basically check if the error was aborted, right? And yeah, it was. And what's really great about the way it is implemented is that basically the implementation details of the HTTP client, which is accessed in this case, didn't leak into our component at all. We didn't have to import it anywhere. Rather, we only have to provide the abort property, and that's where we get the canceler method, set it on a ref, and voila, it's available for us, and we can just call it. Okay, so now let's have a look at how we can use the API layer with GreerQuery. But actually, it's quite simple, because really what we only need to do is import the API method from the API layer, also import whichever hook we need from React Query, like for instance, useQuery in this case. And then we just use it, right? We pass the key for the query, then we pass our list, well, in this case, list users, so the API method, and we just destructure what we need. So for instance, in this case, we get data, we get the refetch method, which can be used to initialize the request, and we also get our normalized state uses. Again, and this part is basically the same as it was with, let's say, with our own useAPI hook, right? And depending on the state use, we render appropriate content. And how can we cancel requests when using API layer with GreerQuery? Well, again, right? We can basically pass an abort property. However, this time, what we are doing is we're not using a ref, but rather, we have a cancel variable, and we assign this cancel method that was passed through the abort property to this cancel variable. Because the thing is that the way React Query works is that it expects a cancel property to be available on the promise that is returned. So that's why we need to do it this way. It also is very concise and clean. And here, finally, if you want to cancel the request, we just need to call cancel queries. That's it for today. Here are the links to the GitHub repo with full code examples, slides for this talk, and websites where you can find me. What's more, I have a special gift for all the conference attendees. React the Road to Enterprise is coming this December. It's an advanced book with best practices, patterns, and techniques that covers many various concepts such as scalable project architecture, local and global state management, testing, API handling, performance optimization, SSG and SSI with Next.js, and more. You can get 35% off with the code REACTADVANCED. Well, I hope you enjoyed this talk and happy coding.