Module: React and Ecosystem
React and Ecosystem·108·8 MIN READ

108: API Boundaries and TanStack Query v5 Queries

TOPICS COVERED: API Boundaries and TanStack Query v5 Queries

Learning objectives

You will learn 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 does not replace HTTP correctness.

First build a 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;
}

Then domain functions:

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

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

Query Client

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 create it during ordinary component rendering.

TanStack Query v5 syntax

This course uses v5 and above only.

Correct:

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.

Query keys are cache identity

A query key should contain every input that changes the result.

Correct:

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

Wrong:

jsx
['tasks']

if the query function secretly reads status, page, and ownerId.

A useful hierarchy:

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

Be consistent.

Status and fetch status

In v5, query state has two important dimensions.

status describes data state:

  • pending
  • error
  • success

fetchStatus describes query-function activity:

  • fetching
  • paused
  • idle

A query can be:

text
status: success
fetchStatus: fetching

when it has usable cached data while a background refetch runs.

Do not replace useful content with a full-page spinner during every background refetch.

Initial pending UI

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>
  );
}

Notice four states:

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

staleTime

staleTime answers:

How long is this data considered fresh?

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

Reference data may stay fresh for a long time.

A rapidly changing queue may need a shorter stale time.

Do not set stale time randomly to stop requests you do not understand.

gcTime

gcTime controls how long unused query data remains in cache before garbage collection.

Freshness and cache retention are different questions.

Do not confuse:

text
staleTime

with:

text
gcTime

Query cancellation

TanStack Query passes an AbortSignal:

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

Pass it through to fetch.

This lets irrelevant work be cancelled where supported.

Dependent queries

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.

Do not create a dependent chain if requests could run in parallel.

Select data

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

select transforms the observed result; it does not mutate the cache value.

Use it for consumers that need a projection.

Pagination

Put page in the key:

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

The old page can remain visible while the new page is fetched.

Indicate stale/pending page change honestly.

Prefetch

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

Use prefetch when user behavior strongly predicts the next resource.

Do not prefetch the entire application by default.

Query ownership

Do not do this:

jsx
const query =
  useQuery(...);

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

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

Now the query cache and component state both claim to own the same server data.

Render from query.data, or create explicit draft state only when editing.

Devtools

TanStack Query Devtools are useful for learning:

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

Use them to understand behavior rather than changing options until requests disappear.

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

Local state such as:

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

is authoritative inside the component.

Server state is a cached observation of remote truth.

A cached task list has questions:

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.

Query lifecycle vocabulary

A query can have cached data and still be fetching.

Example:

text
status = success
fetchStatus = fetching

Meaning:

text
usable data exists
background refetch is happening

UI:

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

Do not replace the list with initial skeleton just because background refresh occurs.

isPending, isLoading, and disabled/dependent queries

In TanStack Query v5, learn the actual state model rather than memorizing one boolean.

isPending is based on status.

isLoading is useful for first fetch situations and is derived from pending + fetching behavior.

A disabled query can be pending without currently fetching.

Inspect:

jsx
query.status
query.fetchStatus

when confused.

Query defaults

By default, cached query data becomes stale quickly and may refetch under common triggers such as mounting/focus/reconnect.

Developers often misinterpret this as "React Query fetches too much."

Instead configure freshness based on domain semantics.

Reference countries:

jsx
staleTime: 24 * 60 * 60 * 1000

Live order queue:

jsx
staleTime: 5_000

or polling/realtime strategy.

Do not set staleTime: Infinity globally just to stop requests.

gcTime

Inactive cache can remain for a period.

This supports:

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

Freshness and retention are independent.

A query can be stale but still cached.

Query key design at scale

Use a consistent factory:

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:

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

Benefits:

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

Be cautious with methods using this if you destructure them; plain functions/objects without this can be simpler.

Stable key serialization

TanStack Query hashes query keys deterministically.

Put serializable values in keys.

Avoid class instances/functions as hidden cache identity.

If Date matters:

jsx
date.toISOString()

can make intent explicit.

Query function errors

Fetch does not reject for 404/500 automatically.

Wrong:

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

A 500 response may resolve and be treated as success.

Correct API wrapper:

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

Query functions need to throw/reject on error.

Retry policy

Not all errors deserve retries.

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;
}

Domain-specific.

A transient network/5xx may retry.

A forbidden request will not become allowed after three attempts.

enabled and lazy thinking

jsx
enabled: Boolean(userId)

is good for a query that cannot be identified without userId.

Avoid using enabled: false as a default "imperative fetch button" architecture for every query.

Queries are declarative:

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

For click-triggered download or mutation-like operations, a mutation/event may be better.

select

Suppose API returns:

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

Consumer only needs completed tasks:

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.

Placeholder versus initial data

placeholderData can temporarily display substitute/previous data while real query resolves.

initialData seeds cache as actual data with freshness semantics.

Use intentionally.

Do not use fake placeholder records that users could mistake for authoritative data.

Prefetch and ensure data

Hover:

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

Later navigation can reuse cache.

Router integration can call ensureQueryData/prefetch-style APIs depending architecture.

Avoid prefetching large datasets on every hover in constrained networks.

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 helps reuse across:

  • component query;
  • prefetch;
  • router loader.

Keep v5 object API throughout.

Structural sharing

TanStack Query tries to preserve references for unchanged JSON-compatible data.

This can reduce downstream rerenders.

Do not deep-clone every response before storing it:

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

That destroys reference preservation and types.

Offline/network mode

For applications with offline expectations, query/mutation network mode and persistence become design topics.

Do not claim "offline support" merely because cached data remains visible.

True offline architecture needs:

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

Failure clinic

Query data copied to Redux

Duplicate ownership.

Query key omits filter

Wrong cached result reused.

404 treated as success

Fetch wrapper did not throw.

Every query uses same staleTime

Domain freshness ignored.

Refetch on every render

Usually caused by unstable architecture or misunderstanding; use Devtools to inspect.

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

Define:

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'];
  },
};

Usage:

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

Mutation can invalidate:

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

while updating detail directly.

Query key design test

Given two requests, ask:

Could they ever return different data?

If yes, their keys must differ.

Examples:

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

Missing tenant/branch/user scope in a key can create serious correctness and even privacy problems in multi-tenant clients if cached views are incorrectly reused.

The API must still enforce tenant security server-side, but cache identity should mirror result identity correctly.


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

TanStack Query v5 also provides Suspense-oriented query APIs.

Conceptually:

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

The component assumes data is available during successful render.

Pending behavior moves to Suspense.

Errors can integrate with Error Boundaries.

This changes component branching:

text
normal useQuery
→ component handles pending/error/success

versus:

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

Neither is universally better. Boundary design and router/framework integration matter.

Error reset

If a query error is thrown to Error Boundary, retry/reset needs coordination between:

text
Error Boundary
Query error state

TanStack Query provides reset-boundary utilities/patterns for this.

Do not build a Retry button that resets only the React boundary while the query remains errored with no retry.

Hydration

Server-rendered applications may:

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

Then useQuery can reuse server-fetched cache.

Be careful not to serialize secrets or cross-user cache state.

Query client lifetime

Browser app:

text
one QueryClient per application session

Server rendering:

text
request-scoped server QueryClient

Do not create QueryClient inside ordinary component render:

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

because rerenders/remounts can destroy cache identity.

Cache is not a database

Query cache is disposable.

The server remains source of truth.

Do not rely on browser query cache as durable persistence for unsaved critical user work unless you intentionally add persistence/draft architecture.