Why You Want React Query
โ ReactJs, React Query, JavaScript, useEffect โ 5 min read
- #1: Practical React Query
- #2: React Query Data Transformations
- #3: React Query Render Optimizations
- #4: Status Checks in React Query
- #5: Testing React Query
- #6: React Query and TypeScript
- #7: Using WebSockets with React Query
- #8: Effective React Query Keys
- #8a: Leveraging the Query Function Context
- #9: Placeholder and Initial Data in React Query
- #10: React Query as a State Manager
- #11: React Query Error Handling
- #12: Mastering Mutations in React Query
- #13: Offline React Query
- #14: React Query and Forms
- #15: React Query FAQs
- #16: React Query meets React Router
- #17: Seeding the Query Cache
- #18: Inside React Query
- #19: Type-safe React Query
- #20: You Might Not Need React Query
- #21: Thinking in React Query
- #22: React Query and React Context
- #23: Why You Want React Query
- #24: The Query Options API
- #25: Automatic Query Invalidation after Mutations
- #26: How Infinite Queries work
- #27: React Query API Design - Lessons Learned
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 auseEffect
.
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:
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 state13}
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.
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.
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:
1function Bookmarks({ category }) {2 const [data, setData] = useState([])3 const [error, setError] = useState()4
5 useEffect(() => {6 let ignore = false7 fetch(`${endpoint}/${category}`)8 .then(res => res.json())9 .then(d => {10 if (!ignore) {11 setData(d)12 }13 })14 .catch(e => {15 if (!ignore) {16 setError(e)17 }18 })19 return () => {20 ignore = true21 }22 }, [category])23
24 // Return JSX based on data and error state25}
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?
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 = false8 setIsLoading(true)9 fetch(`${endpoint}/${category}`)10 .then(res => res.json())11 .then(d => {12 if (!ignore) {13 setData(d)14 }15 })16 .catch(e => {17 if (!ignore) {18 setError(e)19 }20 })21 .finally(() => {22 if (!ignore) {23 setIsLoading(false)24 }25 })26 return () => {27 ignore = true28 }29 }, [category])30
31 // Return JSX based on data and error state32}
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
:
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 = false8 setIsLoading(true)9 fetch(`${endpoint}/${category}`)10 .then(res => res.json())11 .then(d => {12 if (!ignore) {13 setData(d)14 }15 })16 .catch(e => {17 if (!ignore) {18 setError(e)19 }20 })21 .finally(() => {22 if (!ignore) {23 setIsLoading(false)24 }25 })26 return () => {27 ignore = true28 }29 }, [category])30
31 // Return JSX based on data and error state32}
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: dataFromCurrentCategory2error: 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:
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:
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 = false8 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 })17 .catch(e => {18 if (!ignore) {19 setError(e)20 setData(undefined)21 }22 })23 .finally(() => {24 if (!ignore) {25 setIsLoading(false)26 }27 })28 return () => {29 ignore = true30 }31 }, [category])32
33 // Return JSX based on data and error state34}
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.
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 = false8 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 })22 .catch(e => {23 if (!ignore) {24 setError(e)25 setData(undefined)26 }27 })28 .finally(() => {29 if (!ignore) {30 setIsLoading(false)31 }32 })33 return () => {34 ignore = true35 }36 }, [category])37
38 // Return JSX based on data and error state39}
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?
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:
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 state14}
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:
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 state14}
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. โฌ๏ธ