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

Comparison

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-router 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

Your email will never ever be published.

Related posts