Dev.to WebDev 🛠 Dev 👁 0 📖 13 min read

React Query Showed the Wrong User's Data — Here's How to Reproduce and Fix It

A React dashboard can display the wrong user's data even when the API is returning the correct response. One of the easiest ways to introduce this bug is by using a query key that doesn't account for the current user.

A React dashboard can display the wrong user's data even when the API is returning the correct response.

One of the easiest ways to introduce this bug is by using a query key that doesn't account for the current user.

Consider this:

const { data } = useQuery({
  queryKey: ["orders"],
  queryFn: () => fetchOrders(userId),
});

It looks perfectly reasonable.

But there's a problem.

The query function depends on userId, while the cache key doesn't.

If Alice's orders are already cached and the application switches to Bob, both users can end up sharing the same cache entry.

Depending on the query's freshness settings, Bob's dashboard could display Alice's previously cached orders.

I wanted to break down why this happens because it's the kind of issue that's easy to overlook when testing with only one account.

We'll reproduce it in a small React application, fix the query key, and then look at what changes when authentication, logout, and multiple SaaS workspaces are involved.

Alice logs in and opens her orders page. Everything looks fine.

She logs out, and Bob logs in using the same browser.

But Bob sees something unexpected.

Alice's orders are still showing on his dashboard.

You check the backend. The API works. The database queries are correct. Your authorization checks appear to be doing their job.

So why is Bob seeing Alice's data?

The issue might be hiding in one line of React Query code:

queryKey: ["orders"]

That looks harmless, doesn't it?

But when the data depends on the authenticated user, this query key doesn't tell TanStack Query whose orders it's caching.

Let's reproduce the issue, understand why it happens, and fix it properly.

We'll also look at a few related problems that are easy to miss in production: switching workspaces, logging out, and displaying previous data while a new request loads.

1. The bug starts with a perfectly normal-looking query

Consider a React component that fetches the current user's orders.

import { useQuery } from "@tanstack/react-query";

function Orders({ userId }) {
  const { data, isPending } = useQuery({
    queryKey: ["orders"],
    queryFn: () => fetchOrders(userId),
  });

  if (isPending) {
    return <p>Loading orders...</p>;
  }

  return <OrdersTable orders={data} />;
}

At first glance, there's nothing unusual here.

We have a query key, a query function, and a loading state.

The problem is that fetchOrders() depends on userId, but the query key doesn't.

Let's say Alice has these orders:

[
  { "id": 101, "product": "MacBook Pro" },
  { "id": 102, "product": "Keyboard" }
]

And Bob has these:

[
  { "id": 201, "product": "Monitor" },
  { "id": 202, "product": "Mouse" }
]

Our React component requests different data depending on the user.

But TanStack Query sees this:

Alice → ["orders"]

Bob   → ["orders"]

It's the same cache key.

TanStack Query doesn't automatically inspect the variables inside your query function to identify the data.

It relies on the query key you provide.

If two requests use the same key, they're treated as requests for the same cached query.

And that's where things can go wrong.

2. Let's reproduce the problem

Instead of stopping at the explanation, let's create a small React application where you can see the bug yourself.

We'll use Vite and TanStack Query.

No backend or database is required for this demonstration.

The data is mocked so we can focus entirely on caching behavior. We're simulating account switching, not implementing real authentication.

Step 1: Create the project

Run:

npm create vite@latest react-query-cache-demo -- --template react

cd react-query-cache-demo

npm install

npm install @tanstack/react-query

npm run dev

Step 2: Configure QueryClient

Open src/main.jsx and replace its contents with:

import React from "react";
import ReactDOM from "react-dom/client";

import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";

import App from "./App.jsx";

const queryClient = new QueryClient();

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);

Step 3: Create the demo

Now replace src/App.jsx with:

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";

const ordersDatabase = {
  alice: [
    { id: 101, product: "MacBook Pro" },
    { id: 102, product: "Keyboard" },
  ],
  bob: [
    { id: 201, product: "Monitor" },
    { id: 202, product: "Mouse" },
  ],
};

async function fetchOrders(userId) {
  // Simulate network latency
  await new Promise((resolve) =>
    setTimeout(resolve, 500)
  );

  return ordersDatabase[userId];
}

export default function App() {
  const [userId, setUserId] = useState("alice");
  const [fixed, setFixed] = useState(false);

  const {
    data: orders = [],
    isPending,
  } = useQuery({
    queryKey: fixed
      ? ["orders", userId]
      : ["orders"],

    queryFn: () => fetchOrders(userId),

    // Keep cached data fresh for this demo
    staleTime: Infinity,
  });

  return (
    <div style={{ padding: 24 }}>
      <h1>Orders Dashboard</h1>

      <p>
        Logged in as: <strong>{userId}</strong>
      </p>

      <button onClick={() => setUserId("alice")}>
        Switch to Alice
      </button>

      <button onClick={() => setUserId("bob")}>
        Switch to Bob
      </button>

      <hr />

      <label>
        <input
          type="checkbox"
          checked={fixed}
          onChange={(event) =>
            setFixed(event.target.checked)
          }
        />
        Use correct query key
      </label>

      <h2>Orders</h2>

      {isPending ? (
        <p>Loading...</p>
      ) : (
        <ul>
          {orders.map((order) => (
            <li key={order.id}>
              #{order.id} - {order.product}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Step 4: See what happens

Open the application.

Initially, Alice is selected.

Wait for her orders to load.

You should see:

Logged in as: alice

Orders:
#101 - MacBook Pro
#102 - Keyboard

Now click Switch to Bob without enabling the checkbox.

You might expect Bob's orders to appear.

But the screen still shows:

Logged in as: bob

Orders:
#101 - MacBook Pro
#102 - Keyboard

That's the bug.

We're displaying Alice's cached orders while the interface says Bob is the current user.

Why does this happen?

Our query key hasn't changed:

queryKey: ["orders"]

We also deliberately configured:

staleTime: Infinity

This tells TanStack Query to consider the cached data fresh indefinitely, unless it is manually invalidated.

Because the cache key remains unchanged and the data is considered fresh, changing userId doesn't trigger a new query.

This setting makes the problem easy to reproduce.

With the default stale-time configuration, you may see different refetching behavior depending on when the query mounts or is invalidated. But the underlying problem remains: two different datasets are sharing one cache identity.

3. Fixing it with the correct query key

The fix is straightforward.

Instead of:

const { data } = useQuery({
  queryKey: ["orders"],
  queryFn: () => fetchOrders(userId),
});

Use:

const { data } = useQuery({
  queryKey: ["orders", userId],
  queryFn: () => fetchOrders(userId),
});

Now the cache keys are different:

Alice → ["orders", "alice"]

Bob   → ["orders", "bob"]

TanStack Query can distinguish the two datasets.

Go back to the demo and enable Use correct query key.

Switch between Alice and Bob again.

The orders should now match the selected account.

When you switch back to Alice, her previously fetched data can be retrieved from her own cache entry.

That's exactly the behavior we want.

Here's the principle I follow:

If a variable changes which data your query returns, it probably belongs in the query key.

Not every variable needs to be part of the key. But variables that determine the identity of the requested data do.

4. The same mistake becomes more complicated in multi-tenant SaaS applications

Now imagine a more realistic application.

You're working on a SaaS platform where a single account can belong to multiple organizations.

For example:

Alice
  |
  |-- Company A
  |     |-- Orders
  |     |-- Customers
  |
  |-- Company B
        |-- Orders
        |-- Customers

Alice can switch between Company A and Company B without logging out.

You might already be using this query key:

queryKey: ["orders", userId]

It separates Alice's data from Bob's.

But it doesn't separate Company A's orders from Company B's.

Both organizations still share the same key for Alice.

A better key would be:

queryKey: [
  "orders",
  userId,
  organizationId,
]

And what if the orders table has filters?

Suppose users can filter by order status, search terms, or pages.

Those variables also affect the result.

queryKey: [
  "orders",
  userId,
  organizationId,
  {
    status,
    page,
    search,
  },
]

Now each dataset has its own cache identity.

For example:

["orders", "alice", "company-a", { page: 1 }]

["orders", "alice", "company-b", { page: 1 }]

["orders", "bob", "company-b", { page: 1 }]

One important detail: don't include access tokens, passwords, or other secrets in query keys.

Use stable, non-secret identifiers.

And remember that cache scoping is not authorization. Even if a user manipulates a query key, the backend must independently check permissions.

5. How I'd structure the query in a real application

For a workspace-based SaaS application, I prefer keeping the query logic inside a reusable hook.

Something like this:

import { useQuery } from "@tanstack/react-query";

async function fetchWorkspaceOrders({
  workspaceId,
  status,
  page,
  signal,
}) {
  const params = new URLSearchParams({
    status,
    page: String(page),
  });

  const response = await fetch(
    `/api/workspaces/${encodeURIComponent(workspaceId)}/orders?${params}`,
    {
      credentials: "include",
      signal,
    }
  );

  if (response.status === 401) {
    throw new Error("Authentication required");
  }

  if (response.status === 403) {
    throw new Error("Access denied");
  }

  if (!response.ok) {
    throw new Error("Failed to fetch orders");
  }

  return response.json();
}

export function useWorkspaceOrders({
  userId,
  workspaceId,
  status = "all",
  page = 1,
}) {
  return useQuery({
    queryKey: [
      "orders",
      userId,
      workspaceId,
      { status, page },
    ],

    queryFn: ({ signal }) =>
      fetchWorkspaceOrders({
        workspaceId,
        status,
        page,
        signal,
      }),

    enabled: Boolean(userId && workspaceId),
  });
}

There are a few details worth understanding.

The query key describes the data. It includes the account, workspace, and filters that determine the response.

The enabled option prevents automatic fetching until the required identifiers are available. It doesn't remove cached data or enforce access control.

The query function uses the supplied AbortSignal. This allows obsolete requests to be cancelled when TanStack Query cancels the query.

HTTP errors are handled explicitly. The browser's fetch() function doesn't automatically reject a promise for responses such as 403 or 500.

For this example, I'm assuming the backend uses cookie-based authentication. If your application uses bearer tokens or another mechanism, the request implementation will be different.

The query key should still reflect the authenticated data context.

6. Don't forget what happens on logout

Correct query keys solve an important problem, but I wouldn't stop there.

Consider a shared computer.

Alice logs in, opens her dashboard, and logs out.

Then Bob logs in using the same browser tab.

If both sessions use the same QueryClient, Alice's previously fetched data can remain in its in-memory cache even after her session ends.

Correctly scoped keys help prevent the new session from accidentally requesting Alice's cached data.

But for private information, I generally prefer clearing the previous session's cached state when the authentication boundary changes.

TanStack Query provides:

queryClient.clear();

This clears both the query cache and mutation cache associated with that client.

You can also cancel active queries:

await queryClient.cancelQueries();

But the order of operations matters.

A safer logout strategy

The application should immediately stop displaying authenticated screens when logout begins.

Then it should coordinate request cancellation, cache cleanup, and session revocation.

Here's a simplified example:

async function handleLogout() {
  // The application must stop rendering private screens.
  setAuthStatus("signing-out");

  // Cancel active queries.
  await queryClient.cancelQueries();

  // Clear in-memory query and mutation caches.
  queryClient.clear();

  // Revoke the authenticated session on the server.
  const response = await fetch("/api/logout", {
    method: "POST",
    credentials: "include",
  });

  if (!response.ok) {
    // Keep private screens hidden and provide
    // a way to retry or recover from this error.
    setAuthStatus("logout-error");
    return;
  }

  setAuthStatus("signed-out");
}

This assumes your application uses authStatus to stop rendering all protected components when the value isn't an authenticated state.

It's an example of the sequence, not a complete authentication implementation.

A few things are important here:

  • Clearing React Query's cache doesn't revoke a server session.
  • Cancelling a query doesn't undo a request the server has already processed.
  • An unsuccessful logout request must not be presented as successful server-side logout.
  • If you're persisting queries to browser storage, that persisted data must be handled separately.
  • Other caches, global stores, service workers, and open browser tabs may also need attention.

For applications with sensitive information, the full authentication lifecycle deserves its own design and testing.

A cache clear is useful, but it isn't a complete security solution.

7. Another mistake: showing previous data during account switches

There's another subtle situation worth understanding.

TanStack Query supports showing previous query data while the next result loads.

For example:

import {
  keepPreviousData,
  useQuery,
} from "@tanstack/react-query";

const { data } = useQuery({
  queryKey: ["products", page],
  queryFn: () => fetchProducts(page),
  placeholderData: keepPreviousData,
});

This is often useful for pagination.

Instead of briefly displaying an empty table whenever the page changes, you can keep showing the previous page until the new result arrives.

That's a good experience for many interfaces.

But imagine using the same approach when switching between private workspaces.

Even with correctly scoped query keys, previous-data placeholders can temporarily display information belonging to the previous workspace.

The query cache entries may be isolated correctly, while the component still renders the previous result as placeholder data.

These are different behaviors.

For public product listings, this might be harmless.

For customer invoices, medical records, financial reports, or private business dashboards, it could be unacceptable.

A smoother loading experience isn't always the correct loading experience.

When the authenticated user or tenant changes, I generally prefer a loading or empty state over showing data from the previous context.

8. Is this actually a security vulnerability?

It's important to be precise here.

A wrong query key can cause the frontend to display data fetched under a previous authenticated context.

That can create a confidentiality problem, especially on shared devices or when sensitive information remains visible after switching accounts.

But this doesn't automatically mean the backend has an authorization vulnerability.

Consider two different scenarios.

Scenario A: Client-side cache isolation failure

Alice is authorized to access Company A.

The backend correctly returns Company A's orders.

Later, the frontend switches to Company B but mistakenly displays the previously cached Company A orders.

The UI has displayed inappropriate cached data.

That doesn't necessarily mean the server authorized an invalid request.

Scenario B: Broken backend authorization

Alice requests:

GET /api/workspaces/company-b/orders

Even though she doesn't have permission to access Company B.

The server returns Company B's private orders anyway.

That's a backend authorization failure.

Correcting React Query keys won't fix it.

The backend must verify that the authenticated user has access to the requested resource, regardless of what the frontend sends.

In a multi-tenant application, I would treat both areas seriously:

Frontend isolation: Make sure cached data is associated with the correct user and tenant.

Backend authorization: Enforce resource access permissions on every protected request.

Neither replaces the other.

9. Let's add a test for the cache behavior

It's easy to assume a bug is fixed because switching users appears to work manually.

I'd also want a small automated test that verifies how the cache behaves.

Install Vitest:

npm install -D vitest

Create src/query-cache.test.js:

import { describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";

const orders = {
  alice: ["Alice Order"],
  bob: ["Bob Order"],
};

describe("React Query cache isolation", () => {
  it("demonstrates the shared-key mistake", async () => {
    const client = new QueryClient();

    const aliceOrders = await client.fetchQuery({
      queryKey: ["orders"],
      queryFn: async () => orders.alice,
      staleTime: Infinity,
    });

    const bobOrders = await client.fetchQuery({
      queryKey: ["orders"],
      queryFn: async () => orders.bob,
      staleTime: Infinity,
    });

    expect(aliceOrders).toEqual(["Alice Order"]);

    // Bob receives the cached result for Alice.
    expect(bobOrders).toEqual(["Alice Order"]);

    client.clear();
  });

  it("keeps each user's orders separate", async () => {
    const client = new QueryClient();

    const aliceOrders = await client.fetchQuery({
      queryKey: ["orders", "alice"],
      queryFn: async () => orders.alice,
    });

    const bobOrders = await client.fetchQuery({
      queryKey: ["orders", "bob"],
      queryFn: async () => orders.bob,
    });

    expect(aliceOrders).toEqual(["Alice Order"]);
    expect(bobOrders).toEqual(["Bob Order"]);

    client.clear();
  });
});

Run:

npx vitest run

Notice that the first test deliberately expects the wrong result.

It's a regression demonstration: it confirms that the same fresh cache key is being reused.

The second test verifies that distinct keys keep the data separate.

This doesn't replace UI-level or authentication testing. In a production application, I'd also test account switching while requests are in progress, logout behavior, tenant authorization, and persisted cache cleanup.

10. My checklist before shipping a multi-tenant dashboard

When reviewing a React Query implementation, these are the cases I'd verify:

Scenario Expected behavior
Alice opens her orders Alice's orders appear
Alice switches to Bob in the demo Bob's orders appear with a different key
A user switches organizations Only the selected organization's orders appear
Filters or pagination change The query uses the correct cache identity
A user logs out Protected screens stop rendering and cached session data is cleaned up
An old request resolves after logout Its result doesn't reappear in the new session's private UI
An unauthorized workspace is requested The server rejects access
Previous-data placeholders are enabled Private data isn't shown across authentication or tenant boundaries

One detail I find especially important is testing with more than one account.

An application can look completely correct during development when you always test with the same user and workspace.

Context-switching bugs often become visible only when you deliberately switch accounts, organizations, or permissions.

Final thoughts

TanStack Query makes it much easier to manage server state in React applications.

Caching, refetching, request deduplication, loading states, and error handling are all valuable features.

But caching introduces a responsibility that we shouldn't overlook.

The cache needs to know what makes one dataset different from another.

Sometimes that difference is a product ID.

Sometimes it's a page number or filter.

And in SaaS applications, it might be the current user, organization, or workspace.

This small change:

// Too generic for user-specific data
queryKey: ["orders"]

to:

// Separates users and workspaces
queryKey: ["orders", userId, workspaceId]

can prevent a surprisingly confusing class of bugs.

But the bigger lesson isn't simply to add more identifiers to every query key.

It's to think carefully about data ownership, cache lifetime, authentication boundaries, and what the user is actually allowed to see.

A working API doesn't guarantee that the interface is displaying the right data.

That's something worth testing before calling a feature production-ready.

How do you handle React Query caching when users log out or switch workspaces? Do you clear the cache, use user-scoped query keys, or combine both approaches?

I'd be interested to hear how others handle this in production.

Further reading

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.