Learning path · Level 10 · Tutorial 8

Reuse Resource Data Across Routes with TanStack Query

Connect routed React views to a typed query cache. Fetch JSON files you create locally through Vite, reuse resources across routes, and recover from failed reads without a backend.

  • Level: Advanced
  • Duration: 25 min
  • 9 steps
Start tutorial Back to the path
Reuse Resource Data Across Routes with TanStack Query

What you will go through

  1. Give Routed Views One Query Cache Set up one application-level query cache for routed React views before adding project reads. 2 min
  2. Read Typed Resources from Local JSON Create validated data-access functions that read local Vite JSON fixtures and preserve the difference between empty data and failed reads. 3 min
  3. Identify Results with Query Keys Construct result-specific query keys and connect a cancellable, typed list read to TanStack Query. 3 min
  4. Gate Queries and Show the Right Initial State 3 min
  5. Reuse a Project Across Route Consumers Use one detail query entry across the overview and brief routes while keeping list data, route input, and cached resource data in their proper roles. 2 min
  6. Separate Freshness from Cache Retention Configure short freshness and inactive-retention periods, then predict how cached project data behaves during navigation. 3 min
  7. Observe Cache Behavior During Navigation Observe fresh reuse, stale background fetching, and inactive-entry removal while navigating between routed project views. 3 min
  8. Recover Without Discarding Useful Data Configure bounded read retries and recover from initial and background failures without discarding previously successful project data. 3 min
  9. Verify the Complete Routed Read Flow Verify the integrated local application, including guarded route input, cache reuse, freshness behavior, and recovery from read failures. 3 min

What you will learn

  • Connect useQuery to a data-access function that returns validated records or reports a failure.
  • Construct query keys from resource identity and valid route inputs.
  • Reuse cached data between routed consumers without creating a second source of truth in context.
  • Distinguish disabled, initially loading, empty, successful, and background-refresh states.
  • Observe cache freshness and request behavior locally, then recover from an initial or background read failure.

Before you start

  • Represent Detail Views and Filters in the URL
  • Organize React Features by Responsibility
  • Extract Reusable Stateful Logic into Custom Hooks
  • Share Scoped Client State with Context
  • Contain React Rendering Failures with Error Boundaries
  • Validate Unknown Data at Typed API Boundaries
  • Render Loading, Empty, Error, and Recovery States
  • Prevent Outdated Requests from Updating the Interface

Step 1 of 9

Give Routed Views One Query Cache

Set up one application-level query cache for routed React views before adding project reads.

Give resource data its own home

Resource data has a different owner

Project records are resource data: they remain owned outside React even while the application holds an in-memory cached copy. In this tutorial, their source will be read-only JSON served locally by Vite; another application might read the same kind of data from an API.

A query cache handles reuse of resource data and preserves the outcome of reads. That responsibility differs from temporary interaction state, such as whether one route’s help panel is open. Do not copy fetched project records into component state or general context merely to share them between routes.

One shared cache between views

Diagram of two route views using one central query cache, with local JSON resources outside the views and a separate local interface control inside one view.

Route views can consume shared cached resource data while temporary interface details stay local.

Tip

Important boundary

The query cache is in-memory application infrastructure, not durable storage. It can survive route changes while this application instance runs, but a full browser reload evaluates the modules again and starts with a new, empty cache.

Install and provide one stable client

Install the query library

In your own Vite project, install TanStack Query. Then create one QueryClient at module scope in src/main.tsx. This gives the running application one stable cache instead of replacing it during rendering.

Use the default client configuration for now. Later steps add demonstration settings for freshness, retention, and retry behavior.

Terminal

Run this from the project folder.

shell
npm install @tanstack/react-query@5
Expected output
added 2 packages, and audited 214 packages in 2s

found 0 vulnerabilities

The package count, audit details, and elapsed time vary by npm version and existing dependencies.

src/main.tsx

Create or replace src/main.tsx. The provider encloses the router and every routed view. Keep any route-level error boundary below this provider so route recovery does not replace the application cache.

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryClient = new QueryClient();

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </QueryClientProvider>
  </StrictMode>,
);

Keep routed consumers under the cache

Compose the local route shell

Keep route composition below the provider. This navigation shell intentionally has no resource reads yet: later steps replace these temporary route views with query-driven presentations. It uses the three project URLs that the completed application will support.

src/App.tsx

Create src/App.tsx. This default export matches the import in src/main.tsx.

tsx
import { Link, Route, Routes } from 'react-router';
import { ProjectBriefPage } from './features/projects/ProjectBriefPage';
import { ProjectListPage } from './features/projects/ProjectListPage';
import { ProjectPage } from './features/projects/ProjectPage';

export default function App() {
  return (
    <>
      <nav aria-label='Projects'>
        <Link to='/projects?status=active'>Projects</Link>{' '}
        <Link to='/projects/p-101'>Atlas overview</Link>{' '}
        <Link to='/projects/p-101/brief'>Atlas brief</Link>
      </nav>
      <Routes>
        <Route path='/projects' element={<ProjectListPage />} />
        <Route path='/projects/:projectId' element={<ProjectPage />} />
        <Route path='/projects/:projectId/brief' element={<ProjectBriefPage />} />
        <Route path='*' element={<p>Page not found.</p>} />
      </Routes>
    </>
  );
}

Temporary route views

Create these three files under src/features/projects/, splitting the snippet at each file comment.

tsx
// src/features/projects/ProjectListPage.tsx
export function ProjectListPage() {
  return <h1>Projects</h1>;
}

// src/features/projects/ProjectPage.tsx
export function ProjectPage() {
  return <h1>Project overview</h1>;
}

// src/features/projects/ProjectBriefPage.tsx
export function ProjectBriefPage() {
  return <h1>Project brief</h1>;
}

Complete the provider boundary

Complete the provider boundary around the routed application.

tsx
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    
      <BrowserRouter>
        <App />
      </BrowserRouter>
    
  </StrictMode>,
);

Check ownership and cross-route reuse

Match ownership and cache lifetime

Match each value or situation to its appropriate owner or consequence.

Tap an item and then its matching pair.

Step 2 of 9

Read Typed Resources from Local JSON

Create validated data-access functions that read local Vite JSON fixtures and preserve the difference between empty data and failed reads.

Local JSON is still an HTTP resource

Create local resource fixtures

Create these read-only fixtures in your Vite project. Files inside public are served from the site root, so the browser requests /data/projects.json—never /public/data/projects.json.

Create the public/data/projects folder before adding the two detail files.

public/data/projects.json

json
[
  {
    "id": "p-101",
    "name": "Atlas",
    "status": "active",
    "summary": "Map neighborhood walking routes."
  },
  {
    "id": "p-102",
    "name": "Harbor",
    "status": "active",
    "summary": "Coordinate community events."
  }
]

public/data/projects/p-101.json

json
{
  "id": "p-101",
  "name": "Atlas",
  "status": "active",
  "summary": "Map neighborhood walking routes."
}

public/data/projects/p-102.json

json
{
  "id": "p-102",
  "name": "Harbor",
  "status": "active",
  "summary": "Coordinate community events."
}

Build a runtime validation boundary

Validate before returning typed data

Keep transport and runtime validation in src/features/projects/projectData.ts. A successful HTTP response only gives decoded unknown data; it does not prove that the data is a Project.

Start the file with the shared record contract and validators below. The validators are the boundary that prevents invalid JSON values from entering the typed application.

The validated-read boundary

A flow from a local JSON HTTP request through response checking and runtime validation to a typed project, with failures exiting at each stage.

Only data that passes both the HTTP and runtime-shape checks becomes typed resource data.

projectData.ts — types and validators

Begin src/features/projects/projectData.ts with these declarations and validation helpers.

typescript
export type Project = {
  id: string;
  name: string;
  status: 'active' | 'archived';
  summary: string;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function isProject(value: unknown): value is Project {
  return isRecord(value)
    && typeof value.id === 'string'
    && typeof value.name === 'string'
    && (value.status === 'active' || value.status === 'archived')
    && typeof value.summary === 'string';
}

function parseProject(value: unknown): Project {
  if (!isProject(value)) {
    throw new Error('Invalid project data.');
  }

  return value;
}

function parseProjects(value: unknown): Project[] {
  if (!Array.isArray(value) || !value.every(isProject)) {
    throw new Error('Invalid project list data.');
  }

  return value;
}

Preserve the meaning of each outcome

projectData.ts — local read functions

Append these functions to the same projectData.ts file. fetch receives the supplied signal, unsuccessful HTTP responses reject, and response.json() is retained as unknown until validation succeeds.

typescript
async function readJson(url: string, signal: AbortSignal): Promise<unknown> {
  const response = await fetch(url, { signal });

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}.`);
  }

  const decoded: unknown = await response.json();
  return decoded;
}

export async function fetchProjects(
  status: 'all' | Project['status'],
  signal: AbortSignal,
): Promise<Project[]> {
  const decoded = await readJson('/data/projects.json', signal);
  const projects = parseProjects(decoded);

  return status === 'all'
    ? projects
    : projects.filter((project) => project.status === status);
}

export async function fetchProject(
  projectId: string,
  signal: AbortSignal,
): Promise<Project> {
  const decoded = await readJson(`/data/projects/${projectId}.json`, signal);
  const project = parseProject(decoded);

  if (project.id !== projectId) {
    throw new Error('Project ID does not match the requested resource.');
  }

  return project;
}

Empty is data; failure is not

fetchProjects('archived', signal) currently resolves to []: both fixture records are active. That empty array is valid successful data.

By contrast, a non-OK response, malformed JSON, an invalid record shape, or a detail record whose id differs from the requested ID must reject. Do not replace any of those failures with [] or an invented Project.

Warning

Read-only local resources

These JSON files are served as read-only Vite resources. This module performs reads only; it does not treat the files as writable API endpoints.

Practice: preserve success and failure

Complete the typed read flow

Complete the validated-read boundary. Each blank represents one expression or keyword.

typescript
async function readJson(url: string, signal: AbortSignal): Promise<unknown> {
  const response = await fetch(url, {  });

  if () {
    throw new Error(`Request failed: ${response.status}.`);
  }

  const decoded: unknown = await response.json();
  return decoded;
}

async function fetchProjects(
  status: 'all' | Project['status'],
  signal: AbortSignal,
): Promise<Project[]> {
  const decoded = await readJson('/data/projects.json', signal);
  const projects = (decoded);

  return status === 'all'
    ? projects
    : projects.filter((project) => project.status === status);
}

async function fetchProject(projectId: string, signal: AbortSignal): Promise<Project> {
  const decoded = await readJson(`/data/projects/${projectId}.json`, signal);
  const project = parseProject(decoded);

  if (project.id !== projectId) {
     new Error('Project ID does not match the requested resource.');
  }

  return project;
}

Step 3 of 9

Identify Results with Query Keys

Construct result-specific query keys and connect a cancellable, typed list read to TanStack Query.

Give each result an identity

A query key identifies a result

A query key is the identity of a result inside one QueryClient. The request URL describes where data is read from, but it does not by itself separate cached results.

For example, the active-project list and archived-project list read the same /data/projects.json resource. They are different results because the normalized status changes which records fetchProjects returns. Their query keys must therefore differ.

Treat every input that changes a returned result as part of that result’s query key.

One resource, several result identities

A single JSON resource branches into separate cache slots for two filtered lists and one individual project.

The same HTTP resource can support multiple query-cache entries when the returned result depends on different inputs.

Make key inputs explicit

Centralize the key families

Keep key shapes in one factory. This prevents one consumer from writing a list key differently from another consumer.

The list key includes normalized status. The detail key includes the project identifier. The detail signature accepts string | null so the key contract is ready for route input; execution guarding comes in the next step.

Project key factory

In src/features/projects/projectQueries.ts, define the shared result identities.

typescript
import type { Project } from './projectData';

export type ProjectListStatus = 'all' | Project['status'];

export const projectKeys = {
  list: (status: ProjectListStatus) =>
    ['projects', 'list', { status }] as const,
  detail: (projectId: string | null) =>
    ['projects', 'detail', projectId] as const,
};

Inputs that change the key

These examples show why omitting status from a list key is an identity error: two differently filtered results would be assigned the same cache location.

Use the factory rather than reproducing these array shapes elsewhere.
Consumer inputFactory callResult identity
Normalized active filterprojectKeys.list('active')Projects list: active
Normalized archived filterprojectKeys.list('archived')Projects list: archived
Project identifier p-101projectKeys.detail('p-101')Project detail: p-101

Synchronize the key and query function

Connect the typed list read

Add the list Hook beneath the factory. useQuery receives the key and a query function. When the URL-derived filter changes, status changes in both places: a new key selects the appropriate cache entry, and the query function reads the matching result.

The query-function context supplies an AbortSignal. Forward it to the data-access function, which forwards it to fetch. That lets TanStack Query cancel an obsolete read rather than leaving transport cancellation disconnected from query management.

useProjects

Add this to src/features/projects/projectQueries.ts.

typescript
import { useQuery } from '@tanstack/react-query';
import { fetchProjects } from './projectData';

export function useProjects(status: ProjectListStatus) {
  const query = useQuery({
    queryKey: projectKeys.list(status),
    queryFn: ({ signal }) => fetchProjects(status, signal),
  });

  return query;
}

Tip

Keep identity and cancellation connected

Do not hand-write a partial key such as ['projects', 'list']. It loses the filter that distinguishes results. Also do not replace the supplied signal with a newly created controller: the query manager must control the signal that reaches fetch.

Apply key identity and signal forwarding

Complete the keyed, cancellable read

Complete the two result-specific keys and forward the query-managed cancellation signal into the active-list read.

typescript
import { useQuery } from '@tanstack/react-query';
import { fetchProjects } from './projectData';

// Do not use ['projects', 'list']: it omits a result-changing input.
const activeListKey = ;
const atlasDetailKey = ;

export function useActiveProjects() {
  const query = useQuery({
    queryKey: activeListKey,
    queryFn: (context) => fetchProjects('active', ),
  });

  return query;
}

void atlasDetailKey;

Step 4 of 9

Gate Queries and Show the Right Initial State

Separate malformed input from an unavailable resource

Validate route input before interpreting query state

A route parameter is input, not proof that a resource can be read. Normalize it before calling the detail Hook: an identifier that does not match the accepted format becomes null.

  • A malformed URL such as /projects/not-an-id becomes null. It must not start a request.
  • A format-valid URL such as /projects/p-999 can start a request, but its JSON resource may be unavailable. That is a read failure, not an invalid link.
  • A valid, available identifier can read and display its project.

This distinction lets the interface report Invalid project link immediately while valid resource reads remain the query cache's responsibility.

A validity gate before the read

A route validator stops malformed input before a request while valid project input passes through a query cache to a local JSON resource.

Malformed input stops at the gate. A format-valid identifier may read, whether or not a matching resource exists.

Tip

Pending does not always mean loading

A disabled, uncached query may have status: 'pending' and fetchStatus: 'idle'. It is waiting, not loading. Do not use isPending alone to show a loading spinner.

isLoading identifies an active initial fetch: it is true when the query is both pending and fetching. Handle invalid route input before evaluating query flags, and do not offer an action that could explicitly refetch an invalid query.

Build the guarded detail Hook

Normalize every supported URL input

Create the route-input helpers in src/features/projects/projectRoute.ts. isProjectId is exported for code that needs a boolean type guard; normalizeProjectId is the route-facing helper used by both detail pages. Unsupported or absent list-filter values normalize to 'all'.

src/features/projects/projectRoute.ts

typescript
export type ProjectStatusFilter = 'all' | 'active' | 'archived';

export function isProjectId(value: string | undefined): value is string {
  return typeof value === 'string' && /^p-\d{3}$/.test(value);
}

export function normalizeProjectId(value: string | undefined): string | null {
  return isProjectId(value) ? value : null;
}

export function normalizeProjectStatus(value: string | null): ProjectStatusFilter {
  if (value === 'active' || value === 'archived') {
    return value;
  }

  return 'all';
}

Consolidate the query Hooks

Replace the full contents of src/features/projects/projectQueries.ts with this consolidated version. Step 3 already added useQuery; replacing the file avoids a duplicate import while preserving the list Hook and adding the guarded detail Hook.

Call useProject unconditionally. enabled prevents automatic execution for null, while the query-function guard protects fetchProject if code explicitly invokes a disabled query.

src/features/projects/projectQueries.ts

typescript
import { useQuery } from '@tanstack/react-query';
import {
  fetchProject,
  fetchProjects,
  type Project,
} from './projectData';

export type ProjectListStatus = 'all' | Project['status'];

export const projectKeys = {
  list: (status: ProjectListStatus) =>
    ['projects', 'list', { status }] as const,
  detail: (projectId: string | null) =>
    ['projects', 'detail', projectId] as const,
};

export function useProjects(status: ProjectListStatus) {
  const query = useQuery({
    queryKey: projectKeys.list(status),
    queryFn: ({ signal }) => fetchProjects(status, signal),
  });

  return query;
}

export function useProject(projectId: string | null) {
  const query = useQuery({
    queryKey: projectKeys.detail(projectId),
    queryFn: ({ signal }) => {
      if (projectId === null) {
        throw new Error('Invalid project link');
      }

      return fetchProject(projectId, signal);
    },
    enabled: projectId !== null,
  });

  return query;
}

Choose the correct initial interface state

Render input, active initial work, then result

For a detail page, render Invalid project link first when normalization returned null. This branch deliberately does not inspect query flags or offer a refetch action.

For valid input, use query.isLoading for the active initial read. If no data is available after that, render initial-failure feedback. A format-valid but unavailable ID belongs in this failure branch, not in the malformed-link branch.

For a list, availability and emptiness are separate checks. An empty array is successful data, so test its length only after confirming query.data !== undefined.

Initial-state rendering guide

These are initial-state snapshots. Status answers whether a result has succeeded or failed; fetch status answers whether a request is currently active.

A disabled pending query is idle, whereas an initial loading query is actively fetching.
SituationQuery statusFetch statusAppropriate render
Malformed project inputpendingidleInvalid project link
Disabled, uncached detailpendingidleInvalid project link
Initial valid readpendingfetchingLoading feedback
Initial read failureerroridleFailure feedback
Successful filtered list with []successidleNo matching projects
Successful projectsuccessidleProject content

src/features/projects/ProjectListPage.tsx

This complete list view imports the helpers created in this step. The archived filter currently produces a successful empty array because both local fixture records are active.

tsx
import { Link, useSearchParams } from 'react-router';
import { useProjects } from './projectQueries';
import { normalizeProjectStatus } from './projectRoute';

export function ProjectListPage() {
  const [searchParams] = useSearchParams();
  const status = normalizeProjectStatus(searchParams.get('status'));
  const query = useProjects(status);

  if (query.isLoading) {
    return <p>Loading projects…</p>;
  }

  if (query.isError) {
    return <p>Project list could not be loaded.</p>;
  }

  if (query.data === undefined) {
    return <p>Waiting to load projects.</p>;
  }

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

  return (
    <main>
      <h1>Projects</h1>
      <ul>
        {query.data.map((project) => (
          <li key={project.id}>
            <Link to={`/projects/${project.id}`}>{project.name}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Practice: gate reads and classify results

Complete the guards and initial-state checks

Complete the guarded detail query and the two initial-state conditions. The spinner condition must remain false for an uncached query whose automatic execution is disabled.

typescript
export function useProject(projectId: string | null) {
  const query = useQuery({
    queryKey: projectKeys.detail(projectId),
    queryFn: ({ signal }) => {
      if () {
        throw new Error('Invalid project link');
      }

      return fetchProject(projectId, signal);
    },
    enabled: ,
  });

  return query;
}

const showInitialSpinner = ;
const showEmptyList =
  listQuery.data !== undefined && ;

Step 5 of 9

Reuse a Project Across Route Consumers

Use one detail query entry across the overview and brief routes while keeping list data, route input, and cached resource data in their proper roles.

Reuse is keyed, not copied

One resource, two route consumers

ProjectPage and ProjectBriefPage are different presentations of one external resource. When both normalize the route input to the same ID and call useProject, they address the same detail entry in the same query cache.

For example, both routes for p-101 observe the entry identified by the detail query key. The brief can render a shorter view without receiving a copied Project through props, component state, or context.

Shared detail entry

A project overview route and a project brief route both connect to one central detail cache entry, while a list cache entry remains separate.

Equal normalized IDs plus the same detail query key select one shared cached resource.

Tip

Keep one authority

Do not create ProjectContext just to distribute this server-owned record. Context would be a second source of truth that can drift from the query cache. Read query.data in each consumer instead.

A cache hit may make data available immediately. That does not guarantee that no request can occur; the freshness policy controls whether a background refresh is appropriate.

Render two presentations directly from query data

Call the shared Hook in each view

Keep the route-specific presentation in each named page component. Each component normalizes its own route parameter, calls the same Hook, and renders directly from available query data. normalizeProjectId is the route-input helper established earlier; it returns string | null.

src/features/projects/ProjectPage.tsx

tsx
import { Link, useParams } from 'react-router';
import { normalizeProjectId } from './projectRoute';
import { useProject } from './projectQueries';

export function ProjectPage() {
  const { projectId: routeProjectId } = useParams();
  const projectId = normalizeProjectId(routeProjectId);
  const query = useProject(projectId);

  if (projectId === null) return <p>Invalid project link</p>;

  if (query.data !== undefined) {
    const project = query.data;
    return <main><h1>{project.name}</h1><p>{project.summary}</p><Link to={`/projects/${project.id}/brief`}>Read brief</Link></main>;
  }

  if (query.isLoading) return <p>Loading project…</p>;
  return <p>Could not load project.</p>;
}

src/features/projects/ProjectBriefPage.tsx

tsx
import { Link, useParams } from 'react-router';
import { normalizeProjectId } from './projectRoute';
import { useProject } from './projectQueries';

export function ProjectBriefPage() {
  const { projectId: routeProjectId } = useParams();
  const projectId = normalizeProjectId(routeProjectId);
  const query = useProject(projectId);

  if (projectId === null) return <p>Invalid project link</p>;

  if (query.data !== undefined) {
    const project = query.data;
    return <main><h1>{project.name} brief</h1><p>Status: {project.status}</p><Link to={`/projects/${project.id}`}>Project overview</Link></main>;
  }

  if (query.isLoading) return <p>Loading project…</p>;
  return <p>Could not load project.</p>;
}

Practice the shared key

Preserve the shared detail identity

Complete the key expression in the shared Hook. Both route consumers depend on this one detail identity when they pass an equal normalized project ID.

typescript
export function useProject(projectId: string | null) {
  const query = useQuery({
    queryKey: (projectId),
    queryFn: ({ signal }) => {
      if (projectId === null) {
        throw new Error('Invalid project link');
      }

      return fetchProject(projectId, signal);
    },
    enabled: projectId !== null,
  });

  return query;
}

Apply the route-consumer design

Justify cache reuse

Assume both routes normalize their parameter to p-101. Write the query line that belongs in each route component. Then explain why the record should not be copied into ProjectContext, and why an identical record inside a list result is not interchangeable with the detail result.

Write at least 140 characters (0/140).

Step 6 of 9

Separate Freshness from Cache Retention

Configure short freshness and inactive-retention periods, then predict how cached project data behaves during navigation.

Track freshness and inactivity separately

Two independent cache clocks

A successful query result has two separate timers.

  • staleTime: how long the data is fresh after a successful read.
  • gcTime: how long an inactive entry stays in the query cache after its last active consumer leaves.

For this demonstration, data is fresh for 30 s. If neither routed view is using an entry, that inactive entry is retained for 60 s before it can be removed.

These are intentionally short values for observing behavior locally, not universal production defaults.

Freshness versus inactive retention

A diagram shows freshness beginning after a successful read and inactive retention beginning only when no route consumes the entry.

Freshness is measured from successful data. Inactive retention begins later, when the final active consumer leaves.

Configure and predict default behavior

Stale is still usable

Becoming stale does not delete data and does not independently start a request. Stale cached data remains available for rendering.

With the normal mount, window-focus, and reconnect behaviors enabled, a new consumer mount, returning focus, or reconnecting can refetch a stale entry. Those ordinary triggers do not refetch an entry that is still fresh.

An active consumer prevents the inactive-retention countdown. Age alone never causes garbage collection.

What each setting controls

The two settings answer different questions about the same cached result.

Demonstration behavior for a successful project query.
SettingClock startsEffect
staleTimeSuccessful data arrivesData is fresh for 30 s
gcTimeLast active consumer leavesInactive entry is retained for 60 s
Stale entryFreshness endsData remains available
Active entryA consumer is mountedInactive retention does not start

Configure short demonstration timers

In src/main.tsx, replace the module-scope client construction. Keep the existing QueryClientProvider and routed application beneath this stable client.

typescript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 60_000,
    },
  },
});

Practice the configuration

Set the two timers

Complete the query-cache timing configuration.

typescript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: ,
      gcTime: ,
    },
  },
});

Classify freshness and retention outcomes

Classify cache timelines

Match each timeline scenario to its expected data availability and request behavior.

Tap an item and then its matching pair.

Step 7 of 9

Observe Cache Behavior During Navigation

Observe fresh reuse, stale background fetching, and inactive-entry removal while navigating between routed project views.

Preserve successful content

Keep content during a background refresh

A project can already be available from the query cache while TanStack Query performs another read. Do not replace that content with an initial-loading screen.

Use query.data !== undefined to keep rendering the last successful project. When query.isFetching is also true, add the restrained status Refreshing…. The overview and brief links remain usable during this background refresh.

One detail entry, changing query states

Timeline diagram showing fresh reuse, stale background fetching with retained content, an inactive cache entry, and later removal.

Fresh data can be reused without a read. Stale retained data remains visible while a trigger starts a background refresh; an inactive entry can later be collected.

Render the extra fetching state

Replace the result-rendering portion of the established ProjectPage with this pattern. It keeps a cached project visible before considering loading or unavailable-data UI.

tsx
if (query.data !== undefined) {
  const project = query.data;

  return (
    <main aria-busy={query.isFetching}>
      {query.data !== undefined && query.isFetching && (
        <p role='status'>Refreshing…</p>
      )}
      <h1>{project.name}</h1>
      <p>{project.summary}</p>
      <Link to={`/projects/${project.id}/brief`}>Read brief</Link>
    </main>
  );
}

if (query.isLoading) {
  return <p>Loading project…</p>;
}

return <p>Could not load project.</p>;

Run the navigation experiment

Prepare a controlled observation

Open browser DevTools before the experiment. In Network, filter for projects and enable Disable cache while DevTools remains open. This prevents the browser HTTP cache from hiding fixture reads; it does not clear TanStack Query's in-memory query cache.

Use a full browser reload when you need a cold query cache. For a more visible initial or background state, you may temporarily throttle the network.

Navigation cases to compare

Use /projects/p-101 and /projects/p-101/brief. To make the detail entry inactive, navigate to /projects so neither detail route is mounted.

Times are measured from the successful detail read; inactive time begins only when no detail consumer remains.
CaseQuery-cache situationExpected request behavior
First detail after full reloadNo detail entryInitial mount reads the fixture
Overview to brief within 30 sFresh shared detail entryNo mount refetch
Wait past 30 s while viewing detailStale but still activeTime alone starts no read
Return while stale and inactive under 60 sStale retained detail entryRemount can background refresh
Return after over 60 s inactiveDetail entry was collectedNew mount starts an initial read

Tip

Interpret the Network panel carefully

Do not infer one exact request count from a development session. React StrictMode can start and then cancel a request during its extra development mount cycle. A canceled Network entry is an aborted request, not a completed resource read or a read failure. Also account for a stale query refetching when the browser regains focus.

Check the available-data condition

Complete the background-refresh condition

Complete the condition that renders the non-blocking refresh status for an available project during an active fetch.

tsx
return (
  <main aria-busy={query.isFetching}>
    {{} &&  && (
      <p role='status'>Refreshing…</p>
    )}
    <h1>{project.name}</h1>
    <p>{project.summary}</p>
    <Link to={`/projects/${project.id}/brief`}>Read brief</Link>
  </main>
);

Compare your observations

Report what your local app did

After completing the condition, run the fresh, stale-retained, and collected-entry cases in your local app. Keep DevTools Network open with Disable cache enabled. Record observations rather than trying to match a fixed development request count.

Navigation evidence

Write a short report from your local run. Include all of the following:

  1. Moving from /projects/p-101 to /projects/p-101/brief within 30 s: what project content stayed visible, and what fixture request did you observe?
  2. After making the detail entry inactive, returning when its data was older than 30 s but inactive for less than 60 s: what was visible during the return, including any status, and what request behavior occurred?
  3. After leaving the detail entry inactive for more than 60 s: what was visible before the read completed, and what request behavior occurred?
  4. If you saw a canceled development request, explain why it was an abort rather than a completed resource read.

Write at least 180 characters (0/180).

Step 8 of 9

Recover Without Discarding Useful Data

Configure bounded read retries and recover from initial and background failures without discarding previously successful project data.

Set a bounded recovery policy

Bounded read recovery

A failed resource read is ordinary query state, not a rendering crash. Keep throwOnError: false so each routed view can render an expected recovery state. For this local demonstration, retry: 1 permits one managed retry after an unsuccessful read, and retryDelay: 1_000 waits 1 s before that retry.

The existing 30 s staleTime and 60 s gcTime settings remain in place. Mount, window-focus, and reconnect refetch behavior stays enabled because this configuration does not override it.

Two failure paths

Diagram contrasting an initial failed read with no cached project data and a failed background refresh that keeps a cached project visible.

The key question is whether query data is available: no data needs a retry screen, while available data stays visible during a refresh failure.

Configure the shared client

Replace src/main.tsx with this version. App remains the default export established earlier. The stable module-scope client now adds the bounded retry policy.

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 60_000,
      retry: 1,
      retryDelay: 1_000,
      throwOnError: false,
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </QueryClientProvider>
  </StrictMode>,
);

Keep useful data on screen

Let data availability choose the branch

Handle expected HTTP, decoding, and validation failures from query state. A route-level error boundary still protects unexpected rendering failures, but it should not replace ordinary read-recovery UI.

For a valid project route, check query.data !== undefined before making a broad error return. An error with data means the latest background refresh failed; it does not mean the last successful project disappeared. Keep the invalid-link branch first, because it must not offer refetch for a null identifier.

Render initial and refresh failures

Replace src/features/projects/ProjectPage.tsx with this version. Apply the same data-first error structure to ProjectBriefPage, while retaining its brief-specific successful presentation.

tsx
import { Link, useParams } from 'react-router';
import { normalizeProjectId } from './projectRoute';
import { useProject } from './projectQueries';

export function ProjectPage() {
  const { projectId: routeProjectId } = useParams();
  const projectId = normalizeProjectId(routeProjectId);
  const query = useProject(projectId);

  if (projectId === null) {
    return <p>Invalid project link</p>;
  }

  if (query.data === undefined) {
    if (query.isLoading) {
      return <p>Loading project…</p>;
    }

    if (query.isError) {
      return (
        <section>
          <p role='alert'>Could not load project.</p>
          <button type='button' onClick={() => void query.refetch()}>
            Try again
          </button>
        </section>
      );
    }

    return <p>Waiting to load project.</p>;
  }

  const project = query.data;

  return (
    <main aria-busy={query.isFetching}>
      {query.isFetching && <p role='status'>Refreshing…</p>}
      {query.isError && (
        <p role='alert'>Refresh failed. Showing previously loaded data.</p>
      )}
      <h1>{project.name}</h1>
      <p>{project.summary}</p>
      <button
        type='button'
        disabled={query.isFetching}
        onClick={() => void query.refetch()}
      >
        Refresh
      </button>
      <Link to={`/projects/${project.id}/brief`}>Read brief</Link>
    </main>
  );
}

Tip

Why the controls differ

refetch() deliberately requests an update even when cached data is fresh. Disable Refresh while query.isFetching is true to prevent repeated activation. After a terminal refresh failure, fetching has stopped, so Refresh becomes available again once the cause is fixed.

Practice the recovery branches

Complete data-first recovery rendering

Complete the state checks in this rendering fragment. It must provide retry UI for a terminal failure without cached data, retain available data after a terminal refresh failure, and block repeated Refresh activation during an active request.

tsx
if (query.data === undefined) {
  if (query.isLoading) {
    return <p>Loading project…</p>;
  }

  if () {
    return (
      <button type='button' onClick={() => void query.refetch()}>
        Try again
      </button>
    );
  }
}

if (query.data !== undefined) {
  const project = query.data;

  return (
    <main aria-busy={query.isFetching}>
      {{} && (
        <p role='alert'>Refresh failed. Showing previously loaded data.</p>
      )}
      <button
        type='button'
        disabled={}
        onClick={() => void query.refetch()}
      >
        Refresh
      </button>
      <h1>{project.name}</h1>
    </main>
  );
}

return <p>Waiting to load project.</p>;

Verify bounded recovery locally

Induce and recover from local read failures

Use DevTools request blocking while the browser remains online. In Network, block the exact /data/projects/p-101.json request URL.

  1. Open /projects/p-101 and perform a full reload for a cold query cache. Observe the completed failed read and the one managed retry about 1 s later before initial-error feedback appears.
  2. A development StrictMode cycle can also produce a canceled entry. Identify it as cancellation; do not count it as a completed failed read or as the configured retry.
  3. Unblock the URL and choose Try again.
  4. After a successful Atlas load, block the URL again and choose Refresh. Then unblock it and choose Refresh once more.

Do not use offline mode for this experiment: it can pause a query rather than create the failed-read path being tested. Navigation cancellation is also not a recovery failure.

Report the recovery result

After completing the rendering and local experiment, write a 4–6 sentence report. Include the initial-failure branch and background-failure branch you implemented; evidence of the failed initial read followed by its managed retry after about 1 s; how you distinguished any canceled development entry; and what happened after each unblocked refetch. Name the visible Atlas content and feedback from the background-failure case.

Write at least 300 characters (0/300).

Step 9 of 9

Verify the Complete Routed Read Flow

Verify the integrated local application, including guarded route input, cache reuse, freshness behavior, and recovery from read failures.

Audit the completed architecture

One coherent read pipeline

Your finished application has one read pipeline, not several copies of project state:

  • main.tsx creates one module-scope QueryClient and places its provider above the routed app.
  • public/data/ supplies the read-only JSON fixtures through Vite HTTP URLs.
  • projectData.ts validates unknown JSON and rejects failed reads.
  • projectQueries.ts turns normalized inputs into query keys and query functions.
  • ProjectListPage, ProjectPage, and ProjectBriefPage present the query result for their route.

A route value becomes a normalized filter or projectId, then selects a query key. The query function reads and validates the resource. The query cache holds the result, and each route presents that result directly. Do not copy it into context or initialize component state from it.

A full browser reload starts a new in-memory query cache; ordinary route navigation does not.

Route input to cached presentation

Diagram showing a route input normalized into a query key, validated JSON entering a query cache, and two route views consuming the cached project.

The overview and brief are different presentations, but the same valid detail input selects the same detail cache entry.

Final integration check

Complete the guarded detail flow

Complete this integration fragment. Each blank protects a different part of the routed read flow.

tsx
const projectKeys = {
  detail: (projectId: string | null) =>
    ['projects', 'detail', ] as const,
};

export function useProject(projectId: string | null) {
  return useQuery({
    queryKey: projectKeys.detail(projectId),
    queryFn: ({ signal }) => {
      if (projectId === null) {
        return Promise.reject(new Error('Missing project identifier.'));
      }

      return fetchProject(projectId, );
    },
    enabled: ,
  });
}

export function ProjectPage() {
  const { projectId } = useParams();
  const normalizedProjectId = isProjectId(projectId) ? projectId : null;
  const query = useProject(normalizedProjectId);

  if (normalizedProjectId === null) {
    return <p>Invalid project link</p>;
  }

  if () {
    return (
      <main>
        {query.isError && (
          <p role='status'>Refresh failed. Showing previously loaded data.</p>
        )}
        <button
          type='button'
          onClick={() => void query.refetch()}
          disabled={query.isFetching}
        >
          Refresh
        </button>
        <h1>{query.data.name}</h1>
      </main>
    );
  }

  return <p>Loading or initial failure UI goes here.</p>;
}

Verify the local route flow

Run these route checks

Use your local app and the Network panel to collect concise evidence. Keep the earlier timing experiments as your reference; you do not need to repeat every wait.

For request observations, disable the browser HTTP cache in developer tools if you used that setting earlier. This helps expose network behavior but does not clear the in-memory query cache. A full reload does clear that cache.

Verification route matrix

Expected interface outcome and evidence to inspect

Compare interface state with the route input and the query-cache behavior.
Route or actionExpected interface resultEvidence to inspect
`/projects/not-a-project`Invalid project linkNo detail JSON request
`/projects?status=archived`No matching projectsSuccessful empty array, not an error
Open overview, then brief for `p-101` within 30 sAtlas appears in both viewsSame detail key; fresh cached reuse
After earlier successful data, block the exact detail JSON URL and trigger a stale refreshPrevious project remains visible with refresh-failure feedbackA rejected refresh did not erase cached data
Unblock the URL and activate RefreshCurrent project remains or returns visibleA successful explicit refetch recovers the read

Review your observations

Record your evidence

Write a 4–6 sentence verification note from your own local run. Include the two URL edge cases, evidence that overview and brief reused p-101, and what happened when you recovered from a blocked background refresh. Name the evidence you used, such as visible interface feedback or a Network-panel request.

Write at least 220 characters (0/220).

Complete the read flow

Summary

What you can now demonstrate

  • A stable provider and one query cache let routed consumers reuse externally owned resource data without context copies.
  • Normalized URL input selects a result-specific query key; malformed detail input remains null and the guarded query stays disabled.
  • Validated fetch functions either return typed project data or reject, so an empty archived list remains a successful result rather than a failure.
  • Available cached data and fetch status can coexist: preserve content during a background refresh and show refresh failure without replacing it.
  • The completed implementation covers robust cached reads. Coordinating cached data after writes is the next topic.

Tutorial complete

Congratulations! You completed: Reuse Resource Data Across Routes with TanStack Query

You can now build a routed React read flow that validates local resources, identifies them with query keys, reuses cached results across views, and recovers from failed reads without discarding useful data.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate