I have a single-page-app built with React and Vite. It fetches data entirely on the client-side after it has started up. So there's no server at play other than the server that hosts the static assets.
Until yesterday, the app was use swr
to fetch data, now it's using @tanstack/react-query
instead. Why? Because I'm curious. This blog post attempts to jot down some of the difference and contrasts.
If you want to jump straight to the port diff, look at this commit: https://github.com/peterbe/analytics-peterbecom/pull/47/commits/eac4f873303bfb493320b0b4aa0f5f6ba133001a
Bundle phobia
When @tanstack/react-query
first came out, back in the day when it was called React Query, I looked into it and immediately got scared how large it was. I think they've done some great work to remedy that because it's now not much larger than swr
. Perhaps it's because swr
, since wayback when, has grown too.
When I run npm run build
it spits this out:
Before - with swr
vite v5.4.2 building for production...
✓ 1590 modules transformed.
dist/index.html 0.76 kB │ gzip: 0.43 kB
dist/assets/index-CP2W9Ga1.css 0.41 kB │ gzip: 0.24 kB
dist/assets/index-B8iHmcGS.css 196.05 kB │ gzip: 28.94 kB
dist/assets/query-CvwMzO21.js 51.16 kB │ gzip: 18.61 kB
dist/assets/index-ByNQKZOZ.js 79.45 kB │ gzip: 22.69 kB
dist/assets/index-DnpwskLg.js 225.19 kB │ gzip: 72.76 kB
dist/assets/BarChart-CwU8AXdH.js 397.99 kB │ gzip: 112.41 kB
❯ du -sh dist/assets
940K dist/assets
After - with @tanstack/react-query
vite v5.4.2 building for production...
✓ 1628 modules transformed.
dist/index.html 0.76 kB │ gzip: 0.43 kB
dist/assets/index-CP2W9Ga1.css 0.41 kB │ gzip: 0.24 kB
dist/assets/index-B8iHmcGS.css 196.05 kB │ gzip: 28.94 kB
dist/assets/query-CqpLJXAS.js 51.44 kB │ gzip: 18.71 kB
dist/assets/index-BPszumoe.js 77.52 kB │ gzip: 22.14 kB
dist/assets/index-DjC9VFZg.js 250.65 kB │ gzip: 78.88 kB
dist/assets/BarChart-B-D1cgEG.js 400.24 kB │ gzip: 112.94 kB
❯ du -sh dist/assets
964K dist/assets
In this case, it grew the total JS bundle by 26KB. As gzipped, it's 262.28 - 256.08 = 6.2 KB larger
Provider necessary
They work very similar, with small semantic differences (and of course features!) but one important difference is that when you use the useQuery
hook (from import { useQuery } from "@tanstack/react-query"
) you first have to wrap the component in a
provider. Like this:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Nav } from "./components/simple-nav"
import { Routes } from "./routes"
const queryClient = new QueryClient()
export default function App() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<Nav />
<Routes />
</QueryClientProvider>
</ThemeProvider>
)
}
You don't have to do that with when you use useSWR
(from import useSWR from "swr"
). I think I know the why but from an developer-experience point of view, it's quite nice with useSWR
that you don't need that provider stuff.
Basic use
Here's the diff for my app: https://github.com/peterbe/analytics-peterbecom/pull/47/commits/eac4f873303bfb493320b0b4aa0f5f6ba133001a that had the commit message "Port from swr to @tanstack/react-query"
But to avoid having to read that big diff, here's how you use useSWR
:
import useSWR from "swr"
function MyComponent() {
const {data, error, isLoading} = useSWR<QueryResult>(
API_URL,
async (url: string) => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${response.status} on ${response.url}`)
}
return response.json()
}
)
return <div>
{error && <p>Error happened <code>{error.message}</code></p>}
{isLoading && <p>Loading...</p>}
{data && <p>Meaning of life is: <b>{data.meaning_of_life}</b></p>}
</div>
The equivalent using useQuery
looks like this:
import { useQuery } from "@tanstack/react-query"
function MyComponent() {
const { isPending, error, data } = useQuery<QueryResult>({
queryKey: [API_URL],
queryFn: async () => {
const response = await fetch(API_URL)
if (!response.ok) {
throw new Error(`${response.status} on ${response.url}`)
}
return response.json()
}
)
return <div>
{error && <p>Error happened <code>{error.message}</code></p>}
{isPending && <p>Loading...</p>}
{data && <p>Meaning of life is: <b>{data.meaning_of_life}</b></p>}
</div>
Feature comparisons
The TanStack Query website has a more thorough comparison: https://tanstack.com/query/latest/docs/framework/react/comparison
What's clear is: TanStack Query has more features
What you need to consider is; do you need all these features at the expense of a larger JS bundle size? And if size isn't a concern, probably go for TanStack Query based on the simple fact that your needs might evolve and want more powerful functionalities.
To not use the hook
One lovely and simple feature about useSWR
is that it gets "disabled" if you pass it a falsy URL. Consider this:
import useSWR from "swr"
function MyComponent() {
const [apiUrl, setApiUrl] = useState<string | null>(null)
const {data, error, isLoading} = useSWR<QueryResult>(
apiUrl,
async (url: string) => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${response.status} on ${response.url}`)
}
return response.json()
}
)
if (!apiUrl) {
return <div>
<p>Please select your API:</p>
<SelectAPIComponent onChange={(url: string) => {
setApiUrl(url)
}}/>
</div>
}
return <div>
{error && <p>Error happened <code>{error.message}</code></p>}
{isLoading && <p>Loading...</p>}
{data && <p>Meaning of life is: <b>{data.meaning_of_life}</b></p>}
</div>
It's practical and neat. It's not that different with useQuery
except the queryFn
will be called. You just need to remember to return null
.
import { useQuery } from "@tanstack/react-query"
function MyComponent() {
const [apiUrl, setApiUrl] = useState<string | null>(null)
const { isPending, error, data } = useQuery<QueryResult>({
queryKey: [apiUrl],
queryFn: async () => {
// NOTE these 3 lines
if (!apiUrl) {
return null
}
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${response.status} on ${response.url}`)
}
return response.json()
}
)
if (!apiUrl) {
return <div>
<p>Please select your API:</p>
<SelectAPIComponent onChange={(url: string) => {
setApiUrl(url)
}}/>
</div>
}
return <div>
{error && <p>Error happened <code>{error.message}</code></p>}
{isPending && <p>Loading...</p>}
{data && <p>Meaning of life is: <b>{data.meaning_of_life}</b></p>}
</div>
In both of these case, the type (if you hover over it) of that data
variable becomes QueryResult | undefined
.
Pending vs Loading vs Fetching
In simple terms, with useSWR
it's called isLoading
and with useQuery
it's called isPending
.
Since both hooks automatically re-fetch data when the window gets focus back (thanks to the Page Visibility API), when it does so it's called isValidating
with useSWR
and isFetching
with useQuery
.
Persistent storage
In both cases, of my app, I was using localStorage
to keep a default copy of the fetched data. This makes it so that when you load the page initially it 1) populates from localStorage
while waiting for 2) the first fetch
response.
With useSWR
it feels a bit after-thought to add it and you don't get a ton of control. How I solved it with useSWR
was to not touch anything with the useSWR
hook but wrap the parent component (my <App/>
component) in a provider that looked like this:
// main.tsx
import React from "react"
import ReactDOM from "react-dom/client"
import { SWRConfig } from "swr"
import App from "./App.tsx"
import { localStorageProvider } from "./swr-localstorage-cache-provider.ts"
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<SWRConfig value={{ provider: localStorageProvider }}>
<App />
</SWRConfig>
<App />
</React.StrictMode>,
)
// swr-localstorage-cache-provider.ts
import type { Cache } from "swr"
const KEY = "analytics-swr-cache-provider"
export function localStorageProvider() {
let map = new Map<string, object>()
try {
map = new Map(JSON.parse(localStorage.getItem("app-cache") || "[]"))
} catch (e) {
console.warn("Failed to load cache from localStorage", e)
}
window.addEventListener("beforeunload", () => {
const appCache = JSON.stringify(Array.from(map.entries()))
localStorage.setItem(KEY, appCache)
})
return map as Cache
}
With @tanstack/react-query
it feels like it was built from the ground-up with this stuff in mind. A neat thing is that the persistency stuff is a separate plugin so you don't need to make the bundle larger if you don't need persistent storage. Here's how the equivalent solution looks like with @tanstack/react-query
:
First,
npm install @tanstack/query-sync-storage-persister @tanstack/react-query-persist-client
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"
+import { QueryClient } from "@tanstack/react-query"
+import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"
import { Nav } from "./components/simple-nav"
import { Routes } from "./routes"
+const queryClient = new QueryClient()
+const persister = createSyncStoragePersister({
+ storage: window.localStorage,
+})
export default function App() {
return (
<MantineProvider defaultColorScheme={"light"}>
- <QueryClientProvider client={queryClient}>
+ <PersistQueryClientProvider
+ client={queryClient}
+ persistOptions={{ persister }}
+ >
<Nav />
<Routes />
- </QueryClientProvider>
+ </PersistQueryClientProvider
</MantineProvider>
)
}
An important detail that I'm glossing over here is that, in my application, I actually wanted to have only some of the useQuery
hooks to be backed by a persistent client. And I was able to do that. My App.tsx
app used the regular <QueryClientProvider ...>
provider, but deeper in the tree of components and routes and stuff, I went in with the <PersistQueryClientProvider ...>
and it just worked.
The net effect is that when you start up your app, it almost immediately has some data in there, but it starts fetching fresh new data from the backend and that triggers the isFetching
property to be true.
Other differences
Given that this post is just meant to be an introductory skim of the differences, note that I haven't talked about "mutations".
Both frameworks support it. A mutation is basically, like a query but you instead use it with a fetch(url, {method: 'POST', data: ...})
to POST data from the client back to the server.
They both support this but I haven't explored it much yet. At least not enough to make a blog post comparison.
One killer feature that @tanstack/react-query
has that swr
does not is "garbage collection" and "stale time".
If you have dynamic API endpoints that you fetch a lot from, naively useSWR
will cache them all in the browser memory; just in case the same URL gets re-used. But for certain apps, that might be a lot of different fetches and lots of different caching keys. The URLs themselves are tiny, but responses might be large so if you have, over a period of time, too many laying around, it could cause too much memory usage by that browser tab. @tanstac/react-query
has "garbage collection" enabled by default, set to 5 minutes. That's neat!
In summary
Use swr
if your use case is minimal, bundle size is critical, and you don't have grand plans for fancy features that @tanstack/react-query
offers.
Use @tanstack/react-query
if you have more complex needs around offline/online, persistent caching, large number of dynamic queries, and perhaps more demanding needs around offline mutations.
Comments
Great write up. Enjoyed reading it. Thank you.
One thing I noted above, the line were you mention “a killer feature tanstack has…” you mentioned it as react-router. Is that what you meant or did you mean react query? I know react query has a gc and stale time to it.
Thanks for the typo fix!