Have you ever thought about why a specific piece of technology gets popular? Usually there's never a single reason, but I do have a theory that I think is one of the primary drivers.
I call it The 5 O'Clock Rule.
With The 5 O'Clock Rule, the level of abstraction for solving a problem will bubble up until it allows the average developer to stop thinking about the problem.
Unfortunately, it doesn't necessarily matter if the abstraction is elegant, or flexible, or free of leaks. What matters, for the average developer, is that they can close their Jira ticket and go home – no later than 5 o'clock.
Harsh, I know – but I think it's fair.
After all, there's a reason is-string
gets 28 million downloads a week.
On the flip side, there is something magical that happens when an abstraction is both elegant and solves The 5 O'Clock Rule.
This is the story of my favorite example of a piece of technology solving The 5 O'Clock Rule.
A story of how a single developer in a small town in Utah – in his spare time, created a library that is used in one out of every six React applications. For context, that means it gets downloaded 3.3 million times a week and has been downloaded 323 times since you started reading this.
That library, of course, is React Query – and in order to better answer how it solves The 5 O'Clock Rule, we need to take a closer look at what problem it helps developers to stop thinking about.
Believe it or not, that problem is React. To see why, we need to go back to the basics.
In its most fundamental form, React is a library for building user interfaces. It's so simple that, historically, the entire mental model has often been represented as a formula where your View is simply a function of your application's State.
All you have to do is worry about how the state in your application changes, and React will handle the rest.
The primary mode of encapsulation for this concept is the component – which encapsulates both the visual representation of a particular piece of UI as well as the state and logic that goes along with it.
By doing so, the same intuition you have about creating and composing together functions can directly apply to creating and composing components. However, instead of composing functions together to get some value, you can compose components together to get some UI.
In fact, when you think about composition in React, odds are you think in terms of this UI composition since it's what React is so good at.
The problem is in the real world, there's more to building an app than just the UI layer. It's not uncommon to need to compose and reuse non-visual logic as well.
This is the fundamental problem that React hooks were created to solve.
Just like a component enabled the composition and reusability of UI, hooks enabled the composition and reusability of non-visual logic.
- useStatecreate a value that is preserved across renders and triggers a re-render when it changes
- useEffectsynchronize a component with some external system
- useRefcreate a value that is preserved across renders, but won't trigger a re-render when it changes
- useContextget access to what was passed to a Context's Provider
- useReducercreate a value that is preserved across renders and triggers a re-render when it changes, using the reducer pattern
- useMemocache the result of a calculation between renders
- useCallbackcache a function between renders
- useLayoutEffectsynchronize a component with some external system, *before* the browser paints the screen
- useAnythingpart of what makes hooks so composable is you can create your own hooks which leverage React hooks or other custom hooks
The release of hooks ushered in a new era of React – the one I like to call the How the h*ck do we fetch data? era.
What's interesting about all of the built-in hooks that React comes with, as you've probably experienced first hand, is that none of them are dedicated to arguably the most common use-case for building a real world web app – data fetching.
The closest we can get out of the box with React is fetch
ing data inside of useEffect
, and then preserving the response with useState
.
You've undoubtedly seen something like this before.
We're fetching some data from the PokéAPI and showing it to the view – easy enough.
The problem is this is "tutorial" code and unfortunately, you can't write "tutorial" code at work.
The first problem, as you may have noticed if you played with the app, is we're not handling any loading states. This leads to one of the two deadliest UX sins – cumulative layout shift.
There are a few ways to solve this – the simplest being to just show an empty card when the request is in-flight.
To do that, let's add some more state, set it to true
by default, then set it false
once the request is complete. We'll then use that loading
state to determine if we should show the Pokémon or not.
Better, but unfortunately it's still "tutorial" code.
As is, because we're not handling failed requests to the PokéAPI, there's a scenario where our app commits the second of the deadliest UX sins – the infinite loading screen.
Let's take another crack at it by adding in some error
state.
Much better. Now our app is handling the three most common states of a network request – loading, success, and error.
Because we've told useEffect
to synchronize our local pokemon
state with the PokéAPI according to id
, we've taken what has historically been the most complex part of building a web app, an asynchronous side effect, and made it an implementation detail behind simply updating id
.
Unfortunately, we're still not quite done yet. In fact, as is, our code contains the worst kind of bug – one that is both inconspicuous and deceptively wasteful. Can you spot it?
If not,
Whenever we call fetch
, because it's an asynchronous request, we have no idea how long that specific request will take to resolve. It's completely possible that, while we're in the process of waiting for a response, the user clicks one of our buttons, which causes a re-render, which causes our effect to run again with a different id
.
In this scenario, we now have two requests in flight, both with different id
s. Worse, we have no way of knowing which one will resolve first. In both scenarios, we're calling setPokemon
when the request resolves. That means, because we don't know in which order they'll resolve, pokemon
, and therefore our UI, will eventually be whatever request was resolved last. AKA, we have a race condition.
To make it worse, you'll also get a flash of the Pokémon that resolves first, before the second one does.
You can see this in action by playing around with the app. Change the active Pokémon as fast as you can and watch what happens (it's even more obvious if you throttle your network).
That's a pretty subpar experience. How would you go about fixing it? By going deeper down the useEffect
rabbit hole.
Really what we want to do is to tell React to ignore any responses that come from requests that were made in effects that are no longer relevant. In order to do that, of course, we need a way to know if an effect is the latest one. If not, then we should ignore the response and not setPokemon
inside of it.
Ideally, something like this.
try {const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)if (ignore) {return}if (res.ok === false) {throw new Error(`Error fetching pokemon #${id}`)}const json = await res.json()setPokemon(json)setLoading(false)} catch (e) {setError(e.message)setLoading(false)}
To do this, we can utilize useEffect
's cleanup function.
If you return a function from your effect, React will call that function each time before it ever calls your effect again, and then one final time when the component is removed from the DOM.
We can see this in action by adding a cleanup function to our effect that logs the id
that the effect is associated with.
Now play around with the app and notice the logs. Specifically, think of how we can leverage this knowledge of our cleanup function in order to ignore stale responses.
Notice that the cleanup function is only called for id
s that are no longer relevant. This makes sense because the cleanup function for the most recent effect won't be called until either another effect runs (making it stale) or the component has been removed from the DOM (irrelevant in this scenario).
We can use this knowledge, along with the Power of JavaScript™ to fix our problem.
Whenever the effect runs, let's make a variable called ignore
and set it to false
. Then, whenever the cleanup function runs (which we know will only happen when the effect is stale), we'll set ignore
to true
.
Then, all we have to do before we call setPokemon
or setError
, is check to see if ignore
is true
. If it is, then we'll do nothing.
Now, regardless of how many times id
changes, we'll ignore every response that isn't in the most recent effect. This not only makes our app more , but it also improves the UX since React will now only re-render with the latest Pokémon.
So at this point we've got to be finished, right 😅?
If you were to make a PR with this code at work, more than likely someone would ask you to abstract all the logic for handling the fetch
request into a custom hook. If you did that, you'd have two options. Either create a usePokemon
hook, or create a more generic useQuery
hook that could be used for any kind of network request.
Assuming you went with the latter, it would probably look something like this.
I still remember how proud I was when I first made this abstraction. Surely a custom hook like this would be a game changer for making network requests in a React app.
That is, until I started using it.
As is, our custom hook doesn't address another fundamental problem of using state and effects for data fetching: data duplication.
By default, the fetched data is only ever local to the component that fetched it – that's how React works. That means, for every component that needs the same data, we have to refetch it.
That seems minor, but it's not.
Every component will have its own instance of the state and every component has to show a loading indicator to the user while it gets it.
Even worse, it's possible that while fetch
ing to the same endpoint, one request could fail while the other succeeds. Or, one fetch
could lead to data that is different than a subsequent request. Imagine fetching twice from the GitHub API, once receiving that an issue is open
and soon after that it's closed
because it was fixed.
All the predictability that React offers just went out the window.
It may seem unwarranted, but these are the kinds of problems that you will run into when you're fetching async data in a real-world application. To make it worse, these also just happen to be the kinds of problems that very few people think about.
Now if you're an experienced React dev, you might be thinking that if the problem is that we're fetching the same data multiple times, can't we just move that state up to the nearest parent component and pass it down via props?
Or better, put the fetched data on context so that it's available to any component that needs it?
Sure, and if we did that, we'd probably end up with something like this.
Well, it works – but this is the exact type of code that future you will hate current you for.
The biggest change (besides all the Context mess) is since our state is now "global", it needs to be able to store data
| loading
| error
states for multiple url
s. To achieve that, we had to make our state an object where the url
itself is the key.
Now, every time we call useQuery
with a url
, we'll read from the existing state if it exists, or fetch
if it doesn't.
useQuery('/api/rankings') // fetchesuseQuery('/api/rankings') // from cache
With that, we've just introduced a small, in-memory and predictability has been restored.
Unfortunately, we've traded in our predictability problem for an optimization problem.
As you might know, React Context isn't a tool that's particularly good at distributing dynamic data throughout an application since it lacks a fundamental trait of state managers: being able to subscribe to pieces of your state.
As is, any component that calls useQuery
will be subscribed to the whole QueryContext
, and therefore, will re-render whenever anything changes – even if the change isn't related to the url
it cares about.
Also, if two components call useQuery
with the same url
at the same time, unless we can figure out how to dedupe multiple requests, our app will still make two requests since useEffect
is still called once per component.
Oh and since we've introduced a cache, we also need to introduce a way to invalidate it – and as you may know, .
What started out as a simple, innocent pattern for fetching data in a React application has become a coffin of complexity – and unfortunately, there's not just one thing to blame.
-
useEffect
is confusing. -
Context often becomes confusing over time.
-
Combining
useState
,useEffect
, and Context together in an attempt to "manage" state will lead to pain and suffering. -
We're treating asynchronous state as if it were synchronous state.
At this point #1-3 should be obvious, so let's dive into #4.
Synchronous state is state that we're typically used to when working in the browser. It's our state, which is why it's often called client state. We can rely on it to be instantly available when we need it, and no one else can manipulate it, so it's always up-to-date.
Client State
- 1. Client owned: It's always up-to-date.
- 2. Our state: Only we can change it
- 3. Usually ephemeral: It goes away when the browser is closed.
- 4. Synchronous: It's instantly available.
All these traits make client state easy to work with since it's predictable. There isn't much that can go wrong if we're the only ones who can make updates to it.
Asynchronous state, on the other hand, is state that is not ours. We have to get it from somewhere else, usually a server, which is why it's often called server state.
It persists, usually in a database, which means it's not . This makes managing it, particularly over time, tricky.
Server State
- 1. Server owned: What we see is only a snapshot (which can be outdated).
- 2. Owned by many users: Multiple users could change the data.
- 3. Persisted remotely: It exists across browsing sessions.
- 4. Asynchronous: It takes a bit of time for the data to go from the server to the client.
Though it's far too common, it's problematic to treat these two kinds of states as equal.
To manage client state in a React app, we have lots of options available, starting from the built-in hooks like useState
and useReducer
, all the way up to community maintained solutions like redux
or zustand
.
But what are our options for managing server state in a React app?
Historically, there weren't many. That is, until React Query came along.
Ironically, you may have heard that React Query is "the missing piece for data fetching in React".
That couldn't be further from the truth. In fact...
And that's a good thing! Because it should be clear by now that data fetching itself is not the hard part - it's managing that data over time that is.
And while React Query goes very well with data fetching, a better way to describe it is as an async state manager that is also acutely aware of the needs of server state.
In fact, React Query doesn't even fetch any data for you. YOU provide it a promise (whether from fetch
, axios
, graphql
, IndexedDB
, etc.), and React Query will then take the data that the promise resolves with and make it available wherever you need it throughout your entire application.
From there, it can handle all of the dirty work that you're either unaware of, or you shouldn't be thinking about.
- 1. Cache management
- 2. Cache invalidation
- 3. Auto refetching
- 4. Scroll recovery
- 5. Offline support
- 6. Window focus refetching
- 7. Dependent queries
- 8. Paginated queries
- 9. Request cancellation
- 10. Prefetching
- 11. Polling
- 12. Mutations
- 13. Infinite scrolling
- 14. Data selectors
- 15. + More
And the best part about it? You can stop trying to figure out how useEffect
works – which is why it solves the 5 o'clock rule.