DEV Community

The Practical Developer
The Practical Developer

Posted on Originally published at practicaldevblog.blogspot.com

TanStack Query: Server State That Stays Fresh Without the Boilerplate

Two visitors open the same bookshop page a minute apart. The first one buys the last copy of a title. The second visitor's page still says one copy is in stock, because the data was fetched once at load and never checked again. She clicks buy, and the order fails at checkout. That gap is the problem TanStack Query is built for. Data from a server is not like the state inside your app. It lives somewhere else, it changes without telling you, it can fail to arrive, and a copy fetched five minutes ago may be wrong. TanStack Query keeps a cache of that server data, refetches it when it goes stale, and retries when a request fails.

What I would check before using it

My judgement is that TanStack Query earns its place the moment two parts of an app need the same server data, or one screen needs that data to stay fresh. A single fetch on a page that never changes does not need it; a plain fetch inside useEffect does that job with less to learn. The value shows up when the same book list appears on the shop page and the checkout, or when an order should refresh the stock count.

I would check five things before adopting it. First, how the data changes. If the server data never updates, a cache buys little. If it changes when users act, invalidation is the feature you are buying. Second, the defaults. Data is refetched on window focus and on reconnect, and a failed query retries three times. But staleTime defaults to zero, so data counts as stale the moment it arrives; set a staleTime that matches how fast your data changes. Third, the query key model. Every query is named by an array, and that key is how the cache is found, shared, and invalidated. Fourth, the framework. The React hooks are the main way in, and they need React 18 or later. The core package works without React, but most examples and answers assume the hooks. Fifth, the alternatives. SWR is the closest rival and is smaller; a hand-rolled cache works for one screen and becomes a mess by the third.

The verdict: use TanStack Query when server data is shared across screens or needs to stay fresh. That flips if the app makes one fetch that never changes, or the team wants no new concepts this month: then the library is a dependency with no job to do.

A bookshop stock list, fetched and cached

The example is the shop from the opening: a small bookshop with three titles, and an API that lists them with their stock counts. The API is a tiny Node server on port 3456, with a counter that logs every request it receives. That counter is the point of the demo: it proves whether a fetch reached the server or came from the cache. Here is the server:

// server.js
const http = require('node:http');

const BOOKS = [
  { id: 'BK-201', title: 'Learning to Code, Slowly', author: 'Mara Iqbal', price: 24.99, inStock: 14 },
  { id: 'BK-202', title: 'The Quiet Debugger', author: 'Tomas Reyes', price: 31.50, inStock: 6 },
  { id: 'BK-203', title: 'Notes from a Night Shift', author: 'Hana Sato', price: 18.00, inStock: 0 },
];

let requestCount = 0;

const server = http.createServer((req, res) => {
  requestCount += 1;
  console.log('API hit #' + requestCount + ': ' + req.method + ' ' + req.url);
  res.setHeader('Content-Type', 'application/json');

  if (req.url === '/books' && req.method === 'GET') {
    res.writeHead(200);
    res.end(JSON.stringify(BOOKS));
    return;
  }

  const match = req.url.match(/^\/books\/(BK-\d+)$/);
  if (match && req.method === 'GET') {
    const book = BOOKS.find((b) => b.id === match[1]);
    if (!book) {
      res.writeHead(404);
      res.end(JSON.stringify({ error: 'No book with id ' + match[1] }));
      return;
    }
    res.writeHead(200);
    res.end(JSON.stringify(book));
    return;
  }

  res.writeHead(404);
  res.end(JSON.stringify({ error: 'Not found' }));
});

server.listen(3456, () => {
  console.log('Bookshop API listening on http://localhost:3456');
});
Enter fullscreen mode Exit fullscreen mode

The client code uses the framework-agnostic core package, @tanstack/query-core, so it runs in plain Node with no browser and no React. In a React app you would call the useQuery hook with the same key and fetch function; the hooks are a thin layer over this client, so what the core does here is what the hooks do there. The demo asks for the book list three times: once to fetch it, once to test the cache, and once after invalidating it. staleTime is set to 60,000 milliseconds, which tells the client the data can be trusted for a minute:

// demo.js
const { QueryClient } = require('@tanstack/query-core');

async function fetchBooks() {
  const res = await fetch('http://localhost:3456/books');
  if (!res.ok) {
    throw new Error('Request failed with status ' + res.status);
  }
  return res.json();
}

async function main() {
  const client = new QueryClient();

  console.log('--- First fetch ---');
  const first = await client.fetchQuery({
    queryKey: ['books'],
    queryFn: fetchBooks,
    staleTime: 60_000,
  });
  console.log('Books: ' + first.map((b) => b.id + ' (' + b.inStock + ' in stock)').join(', '));

  console.log('--- Second fetch, same key ---');
  const second = await client.fetchQuery({
    queryKey: ['books'],
    queryFn: fetchBooks,
    staleTime: 60_000,
  });
  console.log('Books: ' + second.length + ' books, served from cache');

  console.log('--- Invalidate, then fetch again ---');
  await client.invalidateQueries({ queryKey: ['books'] });
  const third = await client.fetchQuery({
    queryKey: ['books'],
    queryFn: fetchBooks,
    staleTime: 60_000,
  });
  console.log('Books: ' + third.length + ' books after invalidation');

  console.log('--- Cached state ---');
  const state = client.getQueryState(['books']);
  console.log('Status: ' + state.status + ', data updated at: ' + new Date(state.dataUpdatedAt).toISOString());
}

main().catch((err) => {
  console.error('Demo failed:', err.message);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Running the server and the demo gave this real output:

--- First fetch ---
Books: BK-201 (14 in stock), BK-202 (6 in stock), BK-203 (0 in stock)
--- Second fetch, same key ---
Books: 3 books, served from cache
--- Invalidate, then fetch again ---
Books: 3 books after invalidation
--- Cached state ---
Status: success, data updated at: 2026-11-09T04:05:26.504Z
Enter fullscreen mode Exit fullscreen mode

The server log is the proof. It shows only two hits for the whole demo:

Bookshop API listening on http://localhost:3456
API hit #1: GET /books
API hit #2: GET /books
Enter fullscreen mode Exit fullscreen mode

Read the two together. The first fetch hit the server once. The second fetch printed the same three books, but the server log shows no second hit: the data came from the cache, because it was still inside its 60 second fresh window. Then invalidateQueries marked the cached list as stale, and the third fetch hit the server again, which is hit number two. That is the whole model in one run: fetch once, reuse while fresh, refetch when told the data may have changed. In a real app the invalidation would happen after an order is placed, so the stock list updates without a page reload.

Now the part the tidy tutorials skip: something goes wrong. A page asks for one book by id, and the id is wrong. The API answers 404, the fetch function throws, and TanStack Query retries before it gives up. The demo asks for BK-999, a book that does not exist, with two retries allowed:

// fail.js
const { QueryClient } = require('@tanstack/query-core');

async function fetchBook(id) {
  const res = await fetch('http://localhost:3456/books/' + id);
  if (!res.ok) {
    throw new Error('Request failed with status ' + res.status);
  }
  return res.json();
}

async function main() {
  const client = new QueryClient();

  console.log('--- Fetching a book that does not exist ---');
  try {
    await client.fetchQuery({
      queryKey: ['book', 'BK-999'],
      queryFn: () => fetchBook('BK-999'),
      retry: 2,
    });
  } catch (err) {
    console.log('Query failed: ' + err.message);
    const state = client.getQueryState(['book', 'BK-999']);
    console.log('Status: ' + state.status + ', failure count: ' + state.fetchFailureCount);
  }

  console.log('--- Fixed: fetching a book that exists ---');
  const book = await client.fetchQuery({
    queryKey: ['book', 'BK-202'],
    queryFn: () => fetchBook('BK-202'),
    retry: 2,
  });
  console.log('Book: ' + book.title + ' by ' + book.author + ', $' + book.price + ', ' + book.inStock + ' in stock');
}

main().catch((err) => {
  console.error('Demo failed:', err.message);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

The real output:

--- Fetching a book that does not exist ---
Query failed: Request failed with status 404
Status: error, failure count: 3
--- Fixed: fetching a book that exists ---
Book: The Quiet Debugger by Tomas Reyes, $31.5, 6 in stock
Enter fullscreen mode Exit fullscreen mode

The server log shows three hits for BK-999, one for the first try and one for each retry, then a single hit for BK-202. The fix was not in the library. The query was asking for a book that does not exist, so the right fix was to ask for one that does: BK-202, The Quiet Debugger, six in stock. The lesson is that a failed query is usually a wrong question, not a broken cache. Check the id and the URL before you blame the fetching layer, and check the failure count before you assume a request ran once.

Common mistakes and how to fix them

1. Passing a string as the query key. The key must be an array; a string only gets this console warning: As of v4, queryKey needs to be an Array. If you are using a string like 'repoData', please change it to an Array, e.g. ['repoData']. The fetch still runs, so the bug hides until an invalidation misses. The fix is an array: ['books'], with the varying parts in it.

2. Leaving staleTime at zero. With the default of 0, two fetchQuery calls with the same key made two API hits. The fix is a staleTime that matches the data: 60,000 milliseconds for a slow-changing stock list.

3. Forgetting to parse the response. A queryFn that returns fetch(url) without .json() stores the Response object as the data: the demo showed its type as [object Response] and its length as undefined. The fix: parse inside the queryFn and return the parsed JSON.

4. Letting a 404 retry. A query that throws on a missing book is retried by default, so one wrong id cost three API hits. The fix: retry: false for lookups where 404 means "not found".

Where this leaves you

TanStack Query does one job well: it treats server data as something that goes stale, fails, and needs refetching, and it handles all three without a pile of loading flags. It is worth the learning curve when more than one screen shares the same data, or when the data must stay fresh after users act on it. Skip it for a single fetch that never changes. The next step: take one list from your own app, the one fetched in two places today, move it into a query with a key, and watch the second fetch come from the cache. If that feels like less code instead of more, keep going.

Further reading

Originally published at https://practicaldevblog.blogspot.com/2026/11/tanstack-query-server-state-that-stays.html

Top comments (0)