Skip to content
TkDodo's blog
TwitterGithub

Why You Want React Query

ReactJs, React Query, JavaScript, useEffect5 min read

white letters on brown wooden table, 'WANT' with reflection on shop window
Photo by Christian Lue
  • 한국어
  • Español
  • Add translation

It's no secret that I ❤️ React Query for how it simplifies the way we're interacting with asynchronous state in our React applications. And I know a lot of fellow devs feel the same.

Sometimes though, I come across posts claiming that you don't need it to do something as "simple" as fetching data from a server.

We don't need all the extra features that React Query has to offer, so we don't want to add a 3rd party library when we can just as easily fire a fetch in a useEffect.

To some degree, I think that's a valid point - React Query gives you a lot of features like caching, retries, polling, data synchronization, prefetching, ... and about a million more that would go way beyond the scope of this article. It's totally fine if you don't need them, but I still think this shouldn't stop you from using React Query.

So let's instead look at the standard fetch-in-useEffect example that came up on Twitter lately, and dive into why it might be a good idea to use React Query for those situation, too:

fetch-in-useEffect
1function Bookmarks({ category }) {
2 const [data, setData] = useState([])
3 const [error, setError] = useState()
4
5 useEffect(() => {
6 fetch(`${endpoint}/${category}`)
7 .then(res => res.json())
8 .then(d => setData(d))
9 .catch(e => setError(e))
10 }, [category])
11
12 // Return JSX based on data and error state
13}

If you think this code is fine for simple use cases where you don't need additional features, let me tell you that I immediately spotted 🐛 5 bugs 🪲 hiding in these 10 lines of code.

This is fine meme

Maybe take a minute or two and see if you can find them all. I'll wait...


Hint: It's not the dependency array. That is fine.


1. Race Condition 🏎

There are reasons why the official React docs recommend using either a framework or a library like React Query for data fetching. While making the actual fetch request can be a pretty trivial exercise, making that state available predictably in your application is certainly not.

The effect is set up in a way that it re-fetches whenever category changes, which is certainly correct. However, network responses can arrive in a different order than you sent them. So if you change the category from books to movies and the response for movies arrives before the response for books, you'll end up with the wrong data in your component.

Timeline that starts to fetch books, then fetches movies. Movies resolves faster so we setData(movies) before we setData(books)

At the end, you'll be left with an inconsistent state: Your local state will say that you have movies selected, but the data you're rendering is actually books.

The React docs say that we can fix this with a cleanup function and an ignore boolean, so let's do that:

ignore-flag
1function Bookmarks({ category }) {
2 const [data, setData] = useState([])
3 const [error, setError] = useState()
4
5 useEffect(() => {
6 let ignore = false
7 fetch(`${endpoint}/${category}`)
8 .then(res => res.json())
9 .then(d => {
10 if (!ignore) {
11 setData(d)
12 }
13 .catch(e => {
14 if (!ignore) {
15 setError(e)
16 }
17 })
18 return () => {
19 ignore = true
20 }
21 }, [category])
22
23 // Return JSX based on data and error state
24}

What happens now is that the effect cleanup function runs when category changes, setting the local ignore flag to true. If a fetch response comes in after that, it will not call setState anymore. Easy peasy.

2. Loading state 🕐

It's not there at all. We have no way to show a pending UI while the requests are happening - not for the first one and not for further requests. So, let's add that?

loading-state
1function Bookmarks({ category }) {
2 const [isLoading, setIsLoading] = useState(true)
3 const [data, setData] = useState([])
4 const [error, setError] = useState()
5
6 useEffect(() => {
7 let ignore = false
8 setIsLoading(true)
9 fetch(`${endpoint}/${category}`)
10 .then(res => res.json())
11 .then(d => {
12 if (!ignore) {
13 setData(d)
14 }
15 .catch(e => {
16 if (!ignore) {
17 setError(e)
18 }
19 })
20 .finally(() => {
21 if (!ignore) {
22 setIsLoading(false)
23 }
24 })
25 return () => {
26 ignore = true
27 }
28 }, [category])
29
30 // Return JSX based on data and error state
31}

3. Empty state 🗑️

Initializing data with an empty array seems like a good idea to avoid having to check for undefined all the time - but what if we fetch data for a category that has no entries yet, and we actually get back an empty array? We'd have no way to distinguish between "no data yet" and "no data at all". The loading state we've just introduced helps, but it's still better to initialize with undefined:

empty-state
1function Bookmarks({ category }) {
2 const [isLoading, setIsLoading] = useState(true)
3 const [data, setData] = useState()
4 const [error, setError] = useState()
5
6 useEffect(() => {
7 let ignore = false
8 setIsLoading(true)
9 fetch(`${endpoint}/${category}`)
10 .then(res => res.json())
11 .then(d => {
12 if (!ignore) {
13 setData(d)
14 }
15 .catch(e => {
16 if (!ignore) {
17 setError(e)
18 }
19 })
20 .finally(() => {
21 if (!ignore) {
22 setIsLoading(false)
23 }
24 })
25 return () => {
26 ignore = true
27 }
28 }, [category])
29
30 // Return JSX based on data and error state
31}

4. Data & Error are not reset when category changes 🔄

Both data and error are separate state variables, and they don't get reset when category changes. That means if one category fails, and we switch to another one that is fetched successfully, our state will be:

1data: dataFromCurrentCategory
2error: errorFromPreviousCategory

The result will then depend on how we actually render JSX based on this state. If we check for error first, we'll render the error UI with the old message even though we have valid data:

error-first
1return (
2 <div>
3 { error ? (
4 <div>Error: {error.message}</div>
5 ) : (
6 <ul>
7 {data.map(item => (
8 <li key={item.id}>{item.name}</div>
9 ))}
10 </ul>
11 )}
12 </div>
13)

If we check data first, we have the same problem if the second request fails. If we always render both error and data, we're also rendering potentially outdated information . 😔

To fix this, we have to reset our local state when category changes:

reset-state
1function Bookmarks({ category }) {
2 const [isLoading, setIsLoading] = useState(true)
3 const [data, setData] = useState()
4 const [error, setError] = useState()
5
6 useEffect(() => {
7 let ignore = false
8 setIsLoading(true)
9 fetch(`${endpoint}/${category}`)
10 .then(res => res.json())
11 .then(d => {
12 if (!ignore) {
13 setData(d)
14 setError(undefined)
15 }
16 .catch(e => {
17 if (!ignore) {
18 setError(e)
19 setData(undefined)
20 }
21 })
22 .finally(() => {
23 if (!ignore) {
24 setIsLoading(false)
25 }
26 })
27 return () => {
28 ignore = true
29 }
30 }, [category])
31
32 // Return JSX based on data and error state
33}

5. Will fire twice in StrictMode 🔥🔥

Okay, this is more of an annoyance than a bug, but it's definitely something that catches new React developers off guard. If your app is wrapped in <React.StrictMode>, React will intentionally call your effect twice in development mode to help you find bugs like missing cleanup functions.

If we'd want to avoid that, we'd have to add another "ref workaround", which I don't think is worth it.

Bonus: Error handling 🚨

I didn't include this in the original list of bugs, because you'd have the same problem with React Query: fetch doesn't reject on HTTP errors, so you'd have to check for res.ok and throw an error yourself.

error-handling
1function Bookmarks({ category }) {
2 const [isLoading, setIsLoading] = useState(true)
3 const [data, setData] = useState()
4 const [error, setError] = useState()
5
6 useEffect(() => {
7 let ignore = false
8 setIsLoading(true)
9 fetch(`${endpoint}/${category}`)
10 .then(res => {
11 if (!res.ok) {
12 throw new Error('Failed to fetch')
13 }
14 return res.json()
15 })
16 .then(d => {
17 if (!ignore) {
18 setData(d)
19 setError(undefined)
20 }
21 .catch(e => {
22 if (!ignore) {
23 setError(e)
24 setData(undefined)
25 }
26 })
27 .finally(() => {
28 if (!ignore) {
29 setIsLoading(false)
30 }
31 })
32 return () => {
33 ignore = true
34 }
35 }, [category])
36
37 // Return JSX based on data and error state
38}

Our little "we just want to fetch data, how hard can it be?" useEffect hook became a giant mess of spaghetti code 🍝 as soon as we had to consider edge cases and state management. So what's the takeaway here?

Data Fetching is simple.
Async State Management is not.

And this is where React Query comes in, because React Query is NOT a data fetching library - it's an async state manager. So when you say that you don't want it for doing something as simple as fetching data from an endpoint, you're actually right: Even with React Query, you need to write the same fetch code as before.

But you still need it to make that state predictably available in your app as easily as possible. Because let's be honest, I haven't written that ignore boolean code before I used React Query, and likely, neither have you. 😉

With React Query, the above code becomes:

react-query
1function Bookmarks({ category }) {
2 const { isLoading, data, error } = useQuery({
3 queryKey: ['bookmarks', category],
4 queryFn: () =>
5 fetch(`${endpoint}/${category}`).then((res) => {
6 if (!res.ok) {
7 throw new Error('Failed to fetch')
8 }
9 return res.json()
10 }),
11 })
12
13 // Return JSX based on data and error state
14}

That's about 50% of the spaghetti code above, and just about the same amount as the original, buggy snippet was. And yes, this addresses all the bugs we found automatically:

🐛 Bugs

  • 🏎️   There is no race condition because state is always stored by its input (category).
  • 🕐   You get loading, data and error states for free, including discriminated unions on type level.
  • 🗑️   Empty states are clearly separated and can further be enhanced with features like placeholderData.
  • 🔄   You will not get data or error from a previous category unless you opt into it.
  • 🔥   Multiple fetches are efficiently deduplicated, including those fired by StrictMode.

So, if you're still thinking that you don't want React Query, I'd like to challenge you to try it out in your next project. I bet you'll not only wind up with code that is more resilient to edge cases, but also easier to maintain and extend. And once you get a taste of all the features it brings, you'll probably never look back.

Bonus: Cancellation

A lot of folks on twitter mentioned missing request cancellation in the original snippet. I don't think that's necessarily a bug - just a missing feature. Of course, React Query has you covered here as well with a pretty straightforward change:

cancellation
1function Bookmarks({ category }) {
2 const { isLoading, data, error } = useQuery({
3 queryKey: ['bookmarks', category],
4 queryFn: ({ signal }) =>
5 fetch(`${endpoint}/${category}`, { signal }).then((res) => {
6 if (!res.ok) {
7 throw new Error('Failed to fetch')
8 }
9 return res.json()
10 }),
11 })
12
13 // Return JSX based on data and error state
14}

Just take the signal you get into the queryFn, forward it to fetch, and requests will be aborted automatically when category changes. 🎉


That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below. ⬇️