FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·108·18 MIN READ

108: API Boundaries and TanStack Query v5 Queries

TOPICS COVERED: API Boundaries and TanStack Query v5 Queries

Learning objectives

By the end of this lesson, you should be able to:

  • build a small API client boundary before adding cache behavior;
  • classify transport errors and domain errors;
  • configure QueryClient;
  • use TanStack Query v5 object syntax only;
  • design query keys;
  • distinguish status from fetchStatus;
  • reason about stale time and garbage-collection time;
  • build dependent, selected, paginated, and prefetched queries;
  • avoid duplicating query data into component/global state.

API client before cache client

TanStack Query manages server-state caching and synchronization; it does not make an HTTP request correct. Give the query library a request function with a clear contract first. That boundary is where response parsing, HTTP failure handling, credentials, and cancellation belong.

Start with a general request function:

jsx
export class ApiError
  extends Error {
  constructor(
    message,
    {
      status,
      body,
    } = {},
  ) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.body = body;
  }
}

export async function request(
  path,
  {
    signal,
    ...options
  } = {},
) {
  const response =
    await fetch(path, {
      credentials:
        'same-origin',
      signal,
      ...options,
      headers: {
        Accept:
          'application/json',
        ...options.headers,
      },
    });

  let body = null;

  if (
    response.status
    !== 204
  ) {
    const contentType =
      response.headers.get(
        'content-type',
      );

    if (
      contentType?.includes(
        'application/json',
      )
    ) {
      body =
        await response.json();
    }
  }

  if (!response.ok) {
    throw new ApiError(
      body?.error?.message
      ?? `HTTP ${
        response.status
      }`,
      {
        status:
          response.status,
        body,
      },
    );
  }

  return body;
}

There are two useful distinctions here. A transport-level failure is represented by a rejected request or a non-OK HTTP response. A domain-level failure may be encoded in the response body, such as a validation error or a forbidden operation. Keeping the response body and status on ApiError gives the UI and retry policy enough information to respond deliberately instead of treating every failure as an anonymous server error.

Once that boundary exists, add domain-specific functions. They describe the resource and its parameters without making components assemble URLs themselves:

jsx
export function getTasks({
  status,
  page,
  signal,
}) {
  const params =
    new URLSearchParams({
      status,
      page: String(page),
    });

  return request(
    `/api/tasks?${params}`,
    { signal },
  );
}

The query layer can now focus on cache identity and lifecycle. It does not need to know how JSON is parsed or how a 422 differs from a 500.

This boundary also gives failures one predictable shape. Components can inspect an HTTP status when they need to choose a message, while the query library still receives the rejected promise it needs for its error state.

Keeping URL construction in the domain function makes request behavior easier to test. It also prevents individual components from quietly using different parameter names or forgetting a required value.

Query Client

The QueryClient owns the query cache and its defaults. Create it at application scope and provide it to the component tree:

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

const queryClient =
  new QueryClient({
    defaultOptions: {
      queries: {
        retry: 2,
        staleTime: 30_000,
      },
    },
  });

createRoot(root).render(
  <QueryClientProvider
    client={queryClient}
  >
    <App />
  </QueryClientProvider>,
);

Create the QueryClient once. Do not construct it during ordinary component rendering. A new client means a new cache, so rerenders or remounts could make existing data, observers, and invalidation behavior appear to vanish.

In a browser, that normally means one client for the application session. The provider can then give every participating component access to the same cache and observer graph.

On the server, lifetime has a different constraint. A server-rendered request should not accidentally reuse another request's client or its user-specific data.

TanStack Query v5 syntax

This course targets v5 and later.

The supported form used throughout this course is the single object passed to useQuery:

jsx
const query = useQuery({
  queryKey: [
    'tasks',
    {
      status,
      page,
    },
  ],
  queryFn: ({ signal }) =>
    getTasks({
      status,
      page,
      signal,
    }),
});

This course uses the single-object TanStack Query v5 API consistently. Do not introduce positional query overloads from older major versions. Consistency here matters because examples, reusable query options, and the v5 state model should all describe the same API.

Query keys are cache identity

The cache can only distinguish queries using the query key. A query key therefore needs every input that can change the returned data:

jsx
[
  'tasks',
  {
    status,
    page,
    ownerId,
  },
]

This is not enough:

jsx
['tasks']

if the query function secretly reads status, page, and ownerId. In that case, one cache entry could be reused for several different requests. The visible result would depend on which request happened to populate that entry first.

A useful hierarchy looks like this:

text
['tasks']
['tasks', { status: 'open' }]
['task', taskId]
['teams', teamId, 'tasks']

The exact shape is a design choice, but the team needs to use it consistently. A predictable hierarchy makes invalidation and debugging much easier.

Treat a key as a statement of identity, not merely as a label for a screen. The name tasks identifies a resource family; the filters and scope identify the particular observation within that family.

When a request starts returning surprising data, compare the request inputs with the key before inspecting rendering code. A missing input there is often the earliest and most useful diagnosis.

Status and fetch status

In v5, query state has two related but independent dimensions. This is where people commonly confuse “there is no data yet” with “a request is currently running.”

status describes the state of the data:

  • pending
  • error
  • success

fetchStatus describes what the query function is doing:

  • fetching
  • paused
  • idle

Because these dimensions answer different questions, a query can be:

text
status: success
fetchStatus: fetching

That combination means usable cached data is already available while a background refetch runs.

Do not replace useful content with a full-page spinner during every background refetch. Keep the existing content visible and provide a smaller refresh indication when that is useful.

Initial pending UI

An initial query needs explicit handling for pending, error, empty-success, and data-success states:

jsx
function TaskList({
  status,
}) {
  const query =
    useQuery({
      queryKey: [
        'tasks',
        { status },
      ],
      queryFn: ({
        signal,
      }) =>
        getTasks({
          status,
          page: 1,
          signal,
        }),
    });

  if (query.isPending) {
    return (
      <p role="status">
        Loading tasks…
      </p>
    );
  }

  if (query.isError) {
    return (
      <div role="alert">
        <p>
          Could not load
          tasks.
        </p>

        <button
          onClick={() =>
            query.refetch()
          }
        >
          Retry
        </button>
      </div>
    );
  }

  if (
    query.data.tasks
      .length === 0
  ) {
    return (
      <p>
        No tasks found.
      </p>
    );
  }

  return (
    <ul>
      {query.data.tasks.map(
        (task) => (
          <li key={task.id}>
            {task.title}
          </li>
        ),
      )}
    </ul>
  );
}

The four states are:

  • initial pending;
  • error;
  • success + empty;
  • success + data.

An empty result is not an error, and a background fetch is not the same as initial pending. Keeping those distinctions in the UI gives users an honest view of what the application knows.

The error branch also exposes a recovery action without pretending that recovery is guaranteed. refetch() asks the query to try again; the result still has to pass through the same success or error handling.

staleTime

When a request seems to run “too often,” first ask whether the data should still be considered fresh. staleTime answers this question:

How long is this data considered fresh?

For example:

jsx
useQuery({
  queryKey: ['countries'],
  queryFn: getCountries,
  staleTime:
    24 * 60 * 60 * 1000,
});

Reference data may remain fresh for a long time. A rapidly changing queue may need a much shorter stale time. Choose the value from the domain's freshness requirements, not as a random way to suppress requests.

gcTime

gcTime controls how long unused query data remains in the cache before garbage collection. It answers a different question from staleTime.

Freshness asks whether data should be refetched. Retention asks whether unused data should still be available if a component subscribes again. Do not confuse:

text
staleTime

with:

text
gcTime

Data can be stale and still retained in the cache.

For example, returning to a page may show stale retained data immediately and then refetch it. That is different from waiting for a cache entry that has already been collected, and the UI can make the two experiences feel different without inventing data.

Query cancellation

TanStack Query supplies an AbortSignal to the query function:

jsx
useQuery({
  queryKey: [
    'task',
    taskId,
  ],
  queryFn: ({ signal }) =>
    request(
      `/api/tasks/${taskId}`,
      { signal },
    ),
});

Pass that signal through to fetch rather than dropping it at the API boundary. When the query becomes irrelevant, supported work can then be cancelled instead of continuing to consume resources for a result nobody is waiting for.

Dependent queries

Some requests genuinely need data from an earlier request. For example, projects cannot be requested until the current user's ID is known:

jsx
const userQuery =
  useQuery({
    queryKey: ['me'],
    queryFn: getMe,
  });

const projectsQuery =
  useQuery({
    queryKey: [
      'projects',
      userQuery.data?.id,
    ],
    queryFn: ({ signal }) =>
      getProjects({
        userId:
          userQuery.data.id,
        signal,
      }),
    enabled:
      Boolean(
        userQuery.data?.id,
      ),
  });

Use enabled when a query truly cannot run until required input exists. The key includes the ID so each user's projects have distinct cache identity.

Do not create a dependent chain merely for organizational neatness. If two requests do not depend on one another, let them run in parallel.

The enabled condition should describe a real prerequisite, not a workaround for an event flow that belongs elsewhere. If the prerequisite disappears, the key and enabled state should also make it clear why the dependent query is no longer active.

Select data

select is useful when a consumer needs a projection rather than the entire server response:

jsx
const openCountQuery =
  useQuery({
    queryKey: ['tasks'],
    queryFn: getAllTasks,
    select: (data) =>
      data.tasks.filter(
        (task) =>
          !task.completed,
      ).length,
  });

select transforms the result observed by this consumer; it does not mutate the value held in the cache. Use it for projections such as counts, filtered views, or a view-model value, while keeping the source response available to other consumers.

Pagination

Pagination is a cache-identity problem as well as a rendering problem. Put the page, along with any other filter, in the key:

jsx
const query =
  useQuery({
    queryKey: [
      'tasks',
      {
        status,
        page,
      },
    ],
    queryFn: ({ signal }) =>
      getTasks({
        status,
        page,
        signal,
      }),
    placeholderData:
      (previousData) =>
        previousData,
  });

placeholderData allows the old page to remain visible while the new page is fetched. The key still changes, so the pages remain separate cache entries.

If the displayed data is from the previous page, indicate that the page change is still pending or that the data may be stale. Do not make the interface imply that the new page has arrived when it has not.

This is a useful example of separating continuity from certainty. Keeping the previous page avoids an unnecessary blank screen, while a pending indicator tells the user that the visible result is not yet the requested page.

Prefetch

When navigation strongly predicts the next resource, prefetch it:

jsx
await queryClient.prefetchQuery({
  queryKey: [
    'task',
    taskId,
  ],
  queryFn: ({ signal }) =>
    getTask({
      taskId,
      signal,
    }),
});

Prefetching can make the eventual route feel immediate, but it is still real network work. Do not prefetch the entire application by default, particularly on constrained networks.

Query ownership

The query cache should own server data. Avoid copying that data into a second state store:

jsx
const query =
  useQuery(...);

const [tasks, setTasks] =
  useState([]);

useEffect(() => {
  setTasks(query.data);
}, [query.data]);

This creates two owners for the same value. They can diverge, and the effect adds another synchronization step that the UI now has to reason about.

Render from query.data. Create explicit local draft state when a user is editing a value and the draft intentionally differs from the last server response; that is a different ownership decision.

The same rule applies when several components need the resource. They should observe one cache entry rather than each create a private copy and then negotiate synchronization between those copies.

Devtools

TanStack Query Devtools make the cache's behavior visible while you learn and debug. Use them to inspect:

  • cache keys;
  • observers;
  • stale/fresh state;
  • inactive queries;
  • refetches.

The goal is to understand why a request occurred. Do not keep changing options until requests happen to disappear; that hides the architecture problem instead of diagnosing it.

For a concrete investigation, start with the key and the query's active observers. Then check whether the data is stale, whether a focus or reconnect event occurred, and whether a component mounted a different key. Those observations are more useful than guessing at a global setting.

Common mistakes

  • old v4 positional syntax;
  • missing parameters from query keys;
  • using isFetching as initial-loading state;
  • disabling all refetches to hide architecture issues;
  • mirroring query data into Redux or local state;
  • not passing signal;
  • retrying 401/403 validation failures blindly;
  • treating every error as a generic 500.

Exercises

  1. Create a QueryClient and provider.
  2. Build a v5 task query using object syntax.
  3. Add status and page to the key.
  4. Add select for open-count projection.
  5. Add cancellation through signal.
  6. Add prefetch for a task details route.
  7. Explain status, fetchStatus, staleTime, and gcTime.

Exit questions

  1. What makes a good query key?
  2. What is the difference between stale time and GC time?
  3. How can a query be successful and fetching at the same time?
  4. Why should query data not be mirrored into component state?
  5. How does cancellation flow from TanStack Query to fetch?
  6. Why does this course use object syntax only?

Official references


Deep dive: server state has time semantics

The reason query state needs more vocabulary becomes clearer when you compare it with local state. Local state such as:

jsx
const [open, setOpen] = useState(false);

is authoritative inside the component. The component owns the value and decides when to change it.

Server state is different: it is a cached observation of remote truth. A cached task list can become out of date because another user, process, or device changed the server. That means it raises questions local state does not answer by itself:

text
When was it fetched?
Is it still fresh?
Can another user change it?
Should it refetch on focus?
What if offline?
Which mutation invalidates it?
Can multiple components share it?

TanStack Query exists to model those questions rather than forcing each component to build its own synchronization system.

Query lifecycle vocabulary

A query can have cached data and still be fetching. For example:

text
status = success
fetchStatus = fetching

The meaning is:

text
usable data exists
background refetch is happening

That state can have a small refresh indicator without discarding the list:

jsx
{query.isFetching && !query.isPending && (
  <span role="status">Refreshing…</span>
)}

Do not replace the list with its initial skeleton just because a background refresh occurs. The user already has useful content, and hiding it makes a normal synchronization step look like a blank loading state.

isPending, isLoading, and disabled/dependent queries

In TanStack Query v5, use the actual state model instead of memorizing one boolean and applying it everywhere.

isPending is based on status. isLoading is useful for a first-fetch situation and is derived from pending plus fetching behavior. They are related, but they are not interchangeable with every possible query state.

A disabled query can be pending without currently fetching. This is expected for a dependent query whose required input has not arrived yet.

When the UI behavior is surprising, inspect both dimensions directly:

jsx
query.status
query.fetchStatus

That usually tells you whether the issue is missing data, an active request, a paused request, or a query that is not enabled.

Query defaults

By default, cached query data becomes stale quickly and may refetch on common triggers such as mounting, focus, or reconnect. Developers often interpret this as “React Query fetches too much.” The better question is whether the default freshness policy matches this resource.

Configure freshness from domain semantics. For reference countries:

jsx
staleTime: 24 * 60 * 60 * 1000

For a live order queue:

jsx
staleTime: 5_000

or use a polling or realtime strategy when that better describes the product requirement.

Do not set staleTime: Infinity globally just to stop requests. That trades a visible request problem for silently outdated data across unrelated resources.

gcTime

Inactive cache data can remain available for a period after no component observes it. That supports this common navigation flow:

text
navigate away
return soon
show cached data immediately
maybe refetch based on stale state

Freshness and retention remain independent. A query can be stale but still cached, or it can be removed after becoming inactive even though it had not yet become stale.

Query key design at scale

As an application grows, construct keys in one consistent place. A factory can encode the hierarchy and reduce accidental variation:

jsx
export const taskKeys = {
  all: ['tasks'],

  lists() {
    return [...this.all, 'list'];
  },

  list(filters) {
    return [...this.lists(), filters];
  },

  details() {
    return [...this.all, 'detail'];
  },

  detail(id) {
    return [...this.details(), id];
  },
};

Then a component uses the same definition rather than rebuilding the array by hand:

jsx
useQuery({
  queryKey: taskKeys.list({ status, page }),
  ...
});

The benefits are:

  • predictable invalidation;
  • less typo drift;
  • clear hierarchy.

There is one implementation detail worth keeping in mind: methods that use this can behave unexpectedly if you destructure them. Plain functions or objects that do not depend on this may be simpler when the factory is shared broadly.

The factory does not remove the need to review key contents. It only centralizes the shape. Every filter that can alter the server result still belongs in the filters value passed to the factory.

Stable key serialization

TanStack Query hashes query keys deterministically. Put serializable values in keys so that the identity represents the request inputs clearly.

Avoid class instances and functions as hidden cache identity. They make the key harder to inspect and can produce identity behavior that does not communicate the actual resource parameters.

If a date is part of the result identity, make the serialized value explicit:

jsx
date.toISOString()

The key should describe the value that changes the result, not an incidental object reference.

Query function errors

fetch does not reject automatically for HTTP 404 or 500 responses. This wrapper is therefore unsafe:

jsx
queryFn: () => fetch('/api/tasks').then(r => r.json())

A 500 response may resolve normally and be treated as successful query data. The API boundary must check the response and throw:

jsx
if (!response.ok) {
  throw new ApiError(...);
}

Query functions need to throw or reject on failure. Only then can query error state, retry policy, and error UI represent the HTTP outcome correctly.

This is why the API boundary comes before the cache boundary. A cache can retain and retry a rejected operation, but it cannot infer that a resolved 500 response should have been considered a failure after the response body was already accepted as data.

Retry policy

Retrying is not automatically helpful for every error. A transient network problem or a 5xx response may recover; an authentication, authorization, missing-resource, or validation failure will not become valid merely because the client tried again.

For example:

jsx
retry(failureCount, error) {
  if (error.status === 401) return false;
  if (error.status === 403) return false;
  if (error.status === 404) return false;
  if (error.status === 422) return false;

  return failureCount < 2;
}

The policy is domain-specific. A forbidden request will not become allowed after three attempts, while a temporary server or network problem may deserve another try.

enabled and lazy thinking

This is a good use of enabled:

jsx
enabled: Boolean(userId)

The query cannot be identified or executed correctly without userId.

Avoid using enabled: false as the default architecture for every query that is triggered by a button. Queries are declarative:

text
when key/input exists, this cache entry represents this server resource

For a click-triggered download or a mutation-like operation, an event handler or mutation may better express the behavior. Disabling every query turns a cache model into an imperative fetch collection and makes lifecycle behavior harder to reason about.

select

Suppose the API returns:

json
{
  "tasks": [...],
  "meta": {...}
}

If one consumer needs only completed tasks, project that observer's result:

jsx
useQuery({
  ...taskListQueryOptions(filters),
  select(data) {
    return data.tasks.filter((task) => task.completed);
  },
});

This derives observer output without creating a second cache. Do not mutate data; other observers still rely on the original cached response.

Placeholder versus initial data

placeholderData can temporarily display substitute or previous data while the real query resolves. It is presentation-time data for the observer.

initialData seeds the cache as actual data and therefore participates in freshness semantics. That distinction matters: initial data claims the cache has a real starting value, while placeholder data says the displayed value is temporary.

Use each intentionally. Do not create fake placeholder records that users could mistake for authoritative server data.

The distinction is especially important when a placeholder contains fields that look complete. A temporary visual state should be recognizable as temporary, while initialData should come from a source the application is willing to treat as real cached data.

Prefetch and ensure data

A hover is one possible prediction of navigation:

jsx
queryClient.prefetchQuery({
  queryKey: taskKeys.detail(id),
  queryFn: ({ signal }) => getTask({ id, signal }),
});

Later navigation can reuse the cache. Router integration can call ensureQueryData or prefetch-style APIs depending on the application architecture.

Prefetch only where the prediction and cost justify it. Avoid fetching large datasets on every hover when users may be on constrained networks or may never follow the link.

Query options reuse

TanStack Query v5 supports reusable options patterns:

jsx
function taskDetailOptions(id) {
  return queryOptions({
    queryKey: taskKeys.detail(id),
    queryFn: ({ signal }) => getTask({ id, signal }),
    staleTime: 60_000,
  });
}

This lets the same query identity and function be reused by:

  • component query;
  • prefetch;
  • router loader.

Keep the v5 object API throughout. Reuse should reduce drift, not conceal which inputs define the cache entry.

Structural sharing

TanStack Query tries to preserve references for unchanged JSON-compatible data. Preserving those references can reduce downstream rerenders when a refetch returns values that did not actually change.

Do not deep-clone every response before storing it:

jsx
JSON.parse(JSON.stringify(data))

That destroys reference preservation and can also destroy types. Let the cache compare and retain compatible structure unless there is a specific, understood reason to transform the response.

Offline/network mode

Applications that promise offline behavior need more than a cache that happens to remain visible. Query and mutation network modes, persistence, and recovery rules become explicit design topics.

Do not claim “offline support” merely because cached data is displayed. True offline architecture needs:

  • persisted cache if reload should work;
  • queued writes/conflict rules;
  • reconciliation;
  • UX for stale data.

The server, local persistence, and user interface each need a defined role when connectivity disappears and later returns.

Offline behavior also raises conflict questions. A cached read can be displayed safely with a stale label, but an offline write needs a policy for ordering, failure, and reconciliation rather than only a longer cache lifetime.

Failure clinic

Query data copied to Redux

Duplicate ownership. The cache and Redux can disagree, so the application now needs synchronization logic for data TanStack Query already owns.

Query key omits filter

The wrong cached result can be reused because two different requests appear to be the same query.

404 treated as success

The fetch wrapper did not throw. Query state therefore saw a resolved promise instead of an error.

Every query uses same staleTime

Domain freshness was ignored. Reference data and rapidly changing operational data do not necessarily deserve the same policy.

Refetch on every render

This is usually caused by unstable architecture or a misunderstanding of query identity and defaults. Use Devtools to inspect the key, observers, and refetch trigger before changing options.

Deep-dive exercises

  1. Build a query-key factory.
  2. Add retry policy by HTTP class.
  3. Compare stale and inactive cache behavior.
  4. Build dependent user→projects query.
  5. Use select for a derived projection.
  6. Reuse query options in both loader prefetch and component.
  7. Use Devtools to explain every request instead of guessing.

Mastery check

Explain:

  • server-state time semantics;
  • status versus fetchStatus;
  • staleTime versus gcTime;
  • query-key hierarchy;
  • why fetch HTTP errors must be thrown;
  • how prefetching and structural sharing affect UX/performance.

Production case study: dashboard query key architecture

In a dashboard, orders may differ by status, page, branch, detail view, and timeline. Encode those distinctions in a query-key factory rather than relying on callers to remember the structure:

jsx
export const orderKeys = {
  all: ['orders'],

  lists() {
    return ['orders', 'list'];
  },

  list(filters) {
    return ['orders', 'list', filters];
  },

  details() {
    return ['orders', 'detail'];
  },

  detail(orderId) {
    return ['orders', 'detail', orderId];
  },

  timeline(orderId) {
    return ['orders', 'detail', orderId, 'timeline'];
  },
};

Use the factory at the query boundary:

jsx
useQuery({
  queryKey: orderKeys.list({
    status,
    page,
    branchId,
  }),
  queryFn: ({ signal }) =>
    getOrders({
      status,
      page,
      branchId,
      signal,
    }),
});

After a mutation, invalidating the list hierarchy can refresh affected list queries:

jsx
queryClient.invalidateQueries({
  queryKey: orderKeys.lists(),
});

That can happen while updating a detail entry directly when the mutation response contains enough authoritative detail data.

Query key design test

For any two requests, ask:

Could they ever return different data?

If the answer is yes, their keys must differ. Typical differences include:

text
branch A versus branch B
page 1 versus page 2
open versus completed
user t1 versus user t2

Leaving tenant, branch, or user scope out of a key can cause serious correctness problems and, in a multi-tenant client, even privacy problems if a cached view is incorrectly reused. The API must still enforce tenant security on the server, but cache identity should mirror result identity correctly as well.

The design test is simple enough to use in code review: list the inputs that can change the response, then locate each one in the key. If an input is absent, the query is underspecified even if the current screen appears to work.


Additional depth: Suspense queries, error boundaries, and cache ownership

TanStack Query v5 also provides query APIs designed for Suspense. Conceptually:

jsx
const { data } = useSuspenseQuery({
  queryKey: ['task', taskId],
  queryFn: ({ signal }) => getTask({ taskId, signal }),
});

During a successful render, the component can assume data is available. Pending behavior moves to Suspense, and errors can integrate with Error Boundaries.

That changes where the branching happens:

text
normal useQuery
→ component handles pending/error/success

versus:

text
useSuspenseQuery
→ boundary handles pending/error
→ component renders successful data

Neither approach is universally better. Boundary design, router behavior, and framework integration determine which model fits the application.

Error reset

When a query error is thrown to an Error Boundary, retry and reset need coordination between:

text
Error Boundary
Query error state

TanStack Query provides reset-boundary utilities and patterns for that coordination. Do not build a Retry button that resets only the React boundary while the query remains errored and no query retry has occurred.

Hydration

Server-rendered applications may follow a flow like this:

text
create request-scoped QueryClient
prefetch
dehydrate
send safe state
hydrate client

After hydration, useQuery can reuse the server-fetched cache. Be careful not to serialize secrets or cache state belonging to another user. Request scoping and the data selected for dehydration are part of the security boundary.

Hydration is therefore a transfer of selected server state, not permission to copy every server-side object into browser memory. Decide which data is safe and useful to send before enabling the flow.

Query client lifetime

For a browser application, the usual model is:

text
one QueryClient per application session

For server rendering, use:

text
request-scoped server QueryClient

Do not create a QueryClient inside ordinary component render:

jsx
function App() {
  const queryClient = new QueryClient();

Rerenders or remounts can then destroy cache identity. On a server, sharing a client across requests can additionally risk cross-user cache leakage, which is why request scope matters there.

Cache is not a database

The query cache is disposable. The server remains the source of truth.

Do not rely on the browser query cache as durable persistence for unsaved critical user work unless you intentionally add persistence and draft architecture. Cached server observations are useful for responsiveness, but they are not a guaranteed record of work that has not been saved.

Reader page: /react/lesson/108/api-boundaries-and-tanstack-query-v5-queries