An increasing amount of data in our React applications is coming from remote and asynchronous sources and, even worse, continues to masquerade as "global state". In this talk, you'll get the lowdown on why most of your "global state" isn't really state at all and how React Query can help you fetch, cache and manage your asynchronous data with a fraction of the effort and code that you're used to.
Hi everyone. My name's Tanner Linsley, and I'm a co-founder and VP of UI and UX at Nozzle.io, where we build SEO rank tracking software for enterprise.
About Global State
[01:08] Today, a ton of code in our applications is dedicated to consuming and manipulating asynchronous data. Whether that data comes from our users or servers or third party APIs, it's absolutely critical for our apps to provide value to our users. In fact, for a lot of us, our applications are just opinionated user interfaces for consuming and managing this data. Over the years, I've noticed that patterns around accessing and manipulating our data and our applications have quickly taken up residence with what we all know as global state.
Global state is super convenient. It helps us avoid prop drilling and it lets us access data across our application without copying or duplicating it. And it even helps us communicate between isolated components and hooks that otherwise wouldn't be able to. In the end, it just helps us do more with less code. It's extremely accessible and powerful. So it's only natural that we would want all of our important server side data to be just as accessible as our global state. And with that expectation, it's no surprise that we as React developers have chosen to co-locate our server side data with the rest of our global state. It's relatively easy to do this using something like local component state with React context, or even using any number of libraries from the ever-growing list of global state management tools out there. But in the end, the expectation is usually the same. We expect our global state not only to be able to handle trivial things like menu state, themes, things like toasts and alerts, but we also expect it to be responsible for complex life cycles around fetching and providing our server side and asynchronous data to our application.
[03:03] So today I'm here to tell you that despite the fleeting convenience global state gives us when working with server side data, I think we've made a really big mistake placing it there. We've tricked ourselves and our code into thinking that all state is created equal. When I think our asynchronous data in global state could not be more different, especially when it comes to where they're stored, the speed at which we access them, and how we access and update them and ultimately who can make changes to them.
To make all this easier to understand, I want to stop using the term global state and instead call these two different types of state client state and server state.
[03:45] Client state is relatively simple and should be familiar to most developers, it's temporary and local, and it's generally not persisted between sessions. It's accessed with synchronous APIs that don't have any latency. And most of it is owned our client's application instance. So for all those reasons, we can pretty much rely on our client state always being up to date at any given time in our application.
[04:10] Server state, however, is pretty different. Server state is persisted remotely. So the location of source of truth for our server state is potentially unknown or at least outside of our control. It's asynchronous. So we have to access it with asynchronous APIs. And it also has implied shared ownership, which means that it's not just owned our client. It can be read and manipulated both the server and any other potential clients that interact with it. Because of all these things, very few guarantees can actually be made around our server state always being up to date in our apps. And instead we usually end up relying on just snapshots of our async data.
So when we take these two very different types of state, server state and client state, and try and store them in the same system, we'll eventually make trade offs that favor one or the other.
[05:02] A good example of this is that server state has its own unique challenges that we never face with client state. For example, a few of these might be things like caching, deduping requests, updating data in the background, dealing with outdated requests, dealing with mutations, pagination and incremental fetching, or even garbage collection, error memory management, and everything else that comes with caching in general. Many global state patterns don't offer solutions for these kinds of challenges or at the very least attempt to solve them with complicated APIs or over-engineered plug-in systems. And sometimes even overpowered APIs that are basically footguns for the average React developer.
Server state and client state clearly both need plenty of love, but they each need it in their own way. And while I think they have a few things in common when it comes to how we access them in our apps, I think it's time for server state and client state to break up.
[05:59] There is way more to server state than just being globally accessible. And I think it deserves new dedicated tools that not only solve these challenges, but automatically handle them in an elegant way. This is exactly why I decided to build React Query.
React Query - the future for handling the asynchronous data
[06:15] React Query is an NPM library comprised of a couple hooks and utilities that aim to solve asynchronous server state. It's a small API, it's simple, and it's designed to help both novice and advanced React developers be successful while requiring little to zero configuration.
To really know how React Query can drastically transform the way you handle server state, I decided to build a small interactive blog application using React and a little API powered Next.js. So the purpose of this application is pretty simple. It's to show a list of posts, it lets us look at a detailed view of an individual post, and then it allows us to edit existing posts or add new ones as well.
[07:02] I'm going to navigate through a few stages or commits that I made to this project of how this application's state management evolved in the wild and how in the end I finally got to use React Query to save the day.
So first let's just get familiar with the app. We have a trusty sidebar with a single link into our post page. We have a post page that fetches all of our posts from our API and displays them in a list. We can click on a post and load the detailed view with the full content. We can use the edit form at the bottom to edit the post. And then back on our post list, we can use that same form to add a new post to the list.
[07:45] So to do all of this, our app started out with four main components, an app component, which handles all of our routing and routing state, a post component, which fetches our posts from the API, and then displays them with the add new post form underneath them. An individual post component that fetches the full content for a post and renders it, and then gives us the edit form at the bottom. And then finally we have a reusable post form component that just is for editing the post fields.
So in each of these post components, right now we're just using a use effect strategy to call an asynchronous function and fetch our data. And then we use React state to keep track of the loading states for those requests. This way when we mount each of those components, the data gets requested and eventually gets rendered in our UI.
[08:42] And all of this is fine and it works, but I personally don't like having a lot of business logic in my components. So I want to see if we can make this a bit more portable. In this next commit, I've extracted all of the logic for fetching our data and put them into their own custom hooks. So the use posts and use post files now contain all of the same logic and state for fetching our post lists and post detail, but just combined into a custom hook. Then I've also moved all the logic for our mutations into some new files called use create post and use save post that work in a similar way.
So this frees up our components from needing to define all the logic themselves and just focus on rendering the UI, which in my opinion is a great pattern regardless of how you manage your state. So now that we have our fetching logic inside of these reusable hooks, we can use them again anywhere else in our app that we want. Also in this commit, I added a handy little total post count in our sidebar that calls the same used post hook again to show a total count of posts.
[09:51] So with all of this abstraction, it might seem like a big win for code reuse. But if we look at our network tab, we're going to see something weird is happening. Our post endpoint is being called twice. Once for the post list and again for the total post counter. Even though we have extracted the state into reusable hooks, it doesn't really mean that the data inside them is itself reusable. The real reason this is happening actually is because every time we use our used post hook, we're creating a new instance of state and effects for every time that we render it. So if we keep this up, we're going to be double requesting a ton of data for our app over and over again, every time that we use that hook.
In this next commit, I'm going to show you how I try and fix that turning some of the state and fetching logic in our hooks into global components that only get rendered once for the entire app. So I've created a root component for our post list and moved all the fetching logic into that component. This way, we have a single location where our post list fetching can happen for our entire app as long as we render it at the root and only render it once. Since we can only render it once, we have to take all that state and logic and send it down our react component tree through a context object. This way in our used post hook, we can subscribe to that context and get access to our global post list for our app.
[11:16] So let's go see if that solved our problem. No, it did not. It looks like our post endpoint are still being double requested. This might seem weird, but it's actually because anytime we load our used post hook, we need to make sure that it's fetching the data so that it's up to date for the component that's going to use it. But this also means that every instance is going to be calling the fetch function every time that it mounts. Even if they mount at the same time. So luckily I know of a way to fix this and we needed the global state anyway. So at least we didn't waste our time with that. In the next commit, I'm going to show you the easiest way I know how to dedupe asynchronous requests that are happening at the same time.
So we start using a React ref to track the promise from any outgoing requests. Then if any other requests that happen while the promise is still pending, we can simply reuse the promise from the original request so that we don't fire off any extra ones. And each async function can still resolve when the original request comes back.
[12:22] And if we go back to our network panel now, you can see that there's only a single request for our post endpoint and both our post lists and total post counter finished loading at the same time. Which is super awesome. So up to this point, we've been giving our post lists a lot of attention. Let's switch gears and check on our individual post view.
It's great that we can click on a post to see its content, but I knew eventually that I was going to need a way to look up a post its ID as well, which I can't do with this UI yet.
[12:56] So in this next commit, I've added a little search box to my sidebar that lets me enter in a post ID and query my API for it. We'll just go grab an ID from one of these posts, put it in here in search box. If the post is found, it shows the title and lets me click it to open the post, which is super cool. But soon after I added this feature, I noticed another weird thing was happening when I would edit a post that I also had loaded in the sidebar.
So if we edit this post, the post detail gets reloaded with the right title, but the sidebar stays stuck with the old one. And the way our app is structured right now, there isn't really a good way to force that sidebar to reload without introducing some more global state. It was pretty easy to convert our post lists into global state. So I thought it shouldn't be that much harder to do with the individual post state as well. And then in this next commit, I'll show you a few difficult things that I ran into that I didn't have to worry about in the post list state, but how I got around them.
[14:03] So what happened here is that our post state, our post detail state actually needed to store a separate state for every single different detail that we were requesting from the server, including status, data, error, and even its own little promise reference so that we could track and dedupe promises on a per post detail level. It was a bit more work, but it came together okay. And it works just as well as our post list, global state does. And you can see now if we grab a post ID, go search for it in our sidebar, try and update the title. Both the detail view and the one in the side bar will each get updated and even share the same request.
So this is definitely more consistent than what we had before. And even though I felt like we, I guess up to this point, we have accomplished quite a bit with adding global state and all this stuff. There's still a couple of issues at this point that are still bugging me.
[14:58] First off, I think there are way too many loading states getting shown. And honestly, after all the hard work we just did with global state, it was kind of a disappointment that all of the loading states were still happening. Another thing is that when we trigger a fetch for one of our posts, like with the sidebar search, while also looking at the same post in the detail view, the detail view gets put into a loading state too, when it doesn't really need to. And honestly it feels like a bad user experience.
And then the other things that we haven't even looked at yet are things like caching, pagination. And I don't even really want to think about how we could synchronously access our global state right now to do optimistic updates on our mutations.
[15:44] So we could just keep going down this insane rabbit hole of trying to bend global state to our will and handle all these edge cases, but you can take my word for it that it gets pretty complicated very quickly, even for the most advanced global state managers out there, it gets pretty crazy. And with that said, I finally think it's time for React Query to swoop in and save the day.
So in this next commit, I've moved all of our data fetching hooks to use React Query instead of our global state and use effect logic. And as you can see, we were able to blow away about 150 lines of code from our post hooks and our post hooks as well, and replace them with a few lines of react queries using mutation hook... Or no, use query hook. All we had to do was get a unique key for our data and the asynchronous function to go fetch it. So let's check out how that works.
[16:43] All right, it's definitely working. And honestly it feels a lot faster as we move around the app, especially for views and posts that we've already visited. And this is mainly because React Query automatically handles caching and background refetching right out of the box. And when things are cached, they can be rendered immediately next time that they're viewed. And honestly it can feel a bit unreal at times, like when we search for a post in our sidebar and click the link, since the post is already cached from the sidebar look up, the detail view loads instantly, and you'll even notice a little updating status at the top telling us that it was being fetched and reloaded in the background, even though it was reloaded immediately.
So this is all super great. Data fetching is working way better and we got to delete a ton of code. I want to check out now how our mutations are working and how they feel. So adding a post is way better now because it doesn't go into the loading state to fetch the new post. And it just gets background refreshed and added to the end of the list. So we don't get that jarring loading state that we had before. And same with editing a post. We won't see that loading state anymore either. You'll just see a background update happen and a new title will appear.
[18:05] So even with this new button I added to delete a post, it even takes us back to the post page without showing us a loading state and just triggers a background update, which is great. I mean, honestly, all of this is really awesome considering that right now, our mutations are still really naive. In fact, when we fire off our mutations and our UI components right now, we are still having to make sure that we call the refetch function for any queries that we need to update in the background. And while that does work, there is a much easier way to do that I'm going to show you in this next commit.
And in this next commit, I migrate all of our mutation hooks to React Query as well. And the way we do that is with the used mutation hook. And the used mutation hook helps us remove, again, helps us remove a ton of code that was dedicated to handling our mutation state and just replaces it with a single call to use mutation. And then we can pass on our asynchronous mutation function to that. And even if that's all we did was just get rid of a bunch of code, I would be happy with that. But use mutation has a couple more options that help us do way more.
[19:16] The most obvious one you can see here is on success, which is just a function that I've passed that gets called anytime that our mutation succeeds. So inside of that callback, we can use React Query's other import called the query cache to notify any related queries that they need to be refetched.
And since we are doing that there, all of the refetching logic in our components is now gone. And we can just declaratively define our mutation dependencies one at a time inside of our mutation, instead of having to manually call all the queries to refetch every single time that we run a mutation in our UI components.
[19:55] And so up to this point, these things haven't really done anything different for our UI, other than just making the code more maintainable. And at the end of the day, we're still dealing with a server that takes a second or two to apply our changes and get back to us that we need to refetch. To me and probably all of you as well, those few seconds can feel like an eternity.
So I say, what if we can predict the future? What if we could set up our state so that it's going to show what we expect it to show after the server's done, but just do it immediately?
In this next commit, I'm quickly going to show you how to use mutation to do something called optimistic updates. They're a really great way to make your UI feel really fast like you're not even working with asynchronous data, and use mutation has a couple options we can pass to it like the on mutation callback, which runs before our mutation function gets executed. The on-air callback, which gets called when our mutation function fails. And if we combine all of those with React Query's query cache, and its ability to read and write data from our cache synchronously, we can optimistically update our UI. And then in the case that the mutation fails, we can just roll back to a previous value. And if it succeeds, we can trigger a background refetch to make sure that we're seeing the actual server state, instead of just the best guess.
[21:19] So if you watch closely, as I use this ad post form here, you'll see it do exactly that. There's a 50/50 chance it will fail. So sometimes it succeeds and sometimes it fails, but you can see our UI magically rolls back or updates whenever each scenario happens. So our app is now awesome and it feels great. It's very fast and it's really maintainable too.
But all of you saw that when we started this app, it was quick and familiar to jump between those common UI patterns that we lean on when we deal with global state. And even though we put a lot of hard work into those global state patterns, a lot of things can get out of control pretty quick. So because of that, I believe that tools like React Query are the future for handling our asynchronous data. Not only do they solve the challenges with server state, but it also helps us model our global state, and think about our global state with a new perspective. And in some cases, it even removes all of it. Or if not most of it from our application.
Feedback on React Query
[22:22] I've gotten a lot of feedback from people who've used React Query, and people have great things to say about it. I'd like to share a few quick fun ones with you really quick.
My friend, Kent C. Dodds, said that, "React Query is the missing piece to React application development that I've been looking for after years of building React apps. Finally, I have a tool that gives me exactly what I need to solve my application state management problems without giving me more problems. It's fantastic."
Marcelo Alves said, "I still really like Redux, but anytime I remove a piece of the store and replace it with React Query, it's a huge win."
And then Domitrius Clark said, "Finding React Query has helped me maintain an extremely productive workflow inside of React. With queries and mutations, my components express intention clearly, and I can finely tune the UI to a flow my users expect due to the powerful caching strategies. React Query is now my go-to server state manager."
[23:16] So coming full circle here, I'd like to leave everybody with an invitation to go and look at your global state for your applications and take note of just how much of it is server state and how much of it is client state. I think you're going to be surprised at how much of it is actually outside of your application's control, and that opting to handle your server state with a tool like React Query, you'll again be surprised at how little client state you're actually left with and how much better your app's user experience will be.
So thanks for listening and be sure to follow me on Twitter, YouTube and GitHub. And don't forget to check out all my other open source projects and especially my startup Nozzle.io. Thanks.
[23:58] Mettin Parzinski: Got to say, Tanner, that was impressive. Thanks a lot for this great talk. I'm actually looking forward to trying React Query at my own project. I also don't think you charge the hour, right?
Tanner Linsley: Sure, sure.
Mettin Parzinski: You shouldn't do these things if you charge the hour, come on, you're killing us. You're killing us. I want to go jump right into the audience questions. We have a few, and I'm going to start with the first one. Is React Query also spreading GraphQL using Apollo client?
[24:33] Tanner Linsley: So React Query can support GraphQL, but it doesn't do it through Apollo. If you want to use a simple GraphQL client to fetch data, really you can use whatever you want to fetch your data as long as it returns a promise and your data. So I actually have a lot of people who are using just a simple GraphQL client and React Query together, and they say that they love it. So there's a lot of awesome stuff to happen there, but-
[25:11] Mettin Parzinski: Cool. Sounds good. The next question is: Is there something like Apollo, but for more generic ways of communicating with the server than GraphQL?
[25:25] Tanner Linsley: Yeah, that is a good way to put it. It's more generic. It's not built specifically for GraphQL or really any type of specific data layer or protocol. As long as it's transactional based and uses promise returns it'll work.
Mettin Parzinski: You just want promises.
Tanner Linsley: Yeah, I need your promises.
[25:52] Mettin Parzinski: All right. Cool. And then we have another question from Ali. He asks, for the little remaining client state, would you personally use Redux or Context, or what is your go to way of doing this?
[26:07] Tanner Linsley: Yeah, actually I had such little state leftover after I moved everything to React Query that I ended up just moving it to some Context. I use a use reducer and one or two base level components that provide that state through Context to the rest of my app. And honestly, haven't been happier. There's such little state left over that it's super easy to manage on your own.
Mettin Parzinski: Okay.
Tanner Linsley: Oh, and also that was a good question to also answer about Redux. I have a lot of people who have switched over to using React Query for their data fetching and still use Redux to manage their true application state. So they do work well together.
Mettin Parzinski: So they can live side side in harmony.
Tanner Linsley: Mm-hmm (affirmative).
[26:58] Mettin Parzinski: Okay. Question from, well, Nie, I don't know what the correct complete name is. Recently discovered zeit/swr and it looks great. First time seeing React Query in action, and it looks really great too. They seem to accomplish similar things. If you know, could you provide some comparisons and opinion on use cases that maybe suitable for one versus the other, or are they pretty much comparing apples to apples?
[27:28] Tanner Linsley: They are very similar in terms of what they're trying to accomplish. The APIs are different just because they weren't developed in tandem. We each have our own ideas about designing APIs. And I actually have a list of the very minor differences and some really important differences in the React Query readme. There's a little drop down there that says, how is this different from Zeit? And you can go and look at some of the things there that are different. One of the main differences I think is how queries are cached keys. The key structure for React Query is a little different, and in my opinion, a little more flexible so that you can select which queries you want to refetch and what not. And also the entire concept behind used mutation I think is a really useful hook, especially when you start doing things like optimistic updates and wanting to declaratively handle side effects to your mutations. And that's something where that concept doesn't really exist in SWR right now. So that's one of the biggest reasons why I like React Query.
Mettin Parzinski: Well you're prejudiced, I would say.
Tanner Linsley: Yes, I am extremely biased.
Mettin Parzinski: All right, Tanner. That's all the time we have for questions right now. If you have any questions left for Tanner, you can go to his Zoom room. Tanner, thanks a lot.