Learning path · Level 10 · Tutorial 9

Synchronize Cached Data After Mutations

Keep routed views consistent after a data update by coordinating mutations with cache updates and refreshes. Practice reads and writes with an in-memory adapter you create locally, handling failure and recovery without claiming server persistence.

  • Level: Advanced
  • Duration: 25 min
  • 8 steps
Start tutorial Back to the path
Synchronize Cached Data After Mutations

What you will go through

  1. Identify What Must Change After a Save Trace a Task Board edit from the editable draft to the simulated source and separately cached routed views. 2 min
  2. Connect Reads and Writes to One Local Adapter Replace static task reads with one typed browser-only in-memory adapter, then connect routed list and detail queries to it. 5 min
  3. Submit the Draft as a Mutation Save a typed Task Board form draft through a guarded TanStack Query mutation while keeping the confirmed in-memory write distinct from cached routed views. 4 min
  4. Invalidate the Views Affected by the Update Refresh all affected cached task views after a confirmed update by invalidating list variants and the edited detail entry. 3 min
  5. Use Confirmed Results Where They Are Sufficient Use a confirmed mutation result to update the exact detail cache safely, while invalidating broader list views that need a refresh. 3 min
  6. Recover from a Rejected Write Without Losing Input Preserve an editable form draft and confirmed routed data when the local in-memory adapter rejects a write, then recover through one deliberate resubmission. 2 min
  7. Separate Save Success from Refresh Failure Handle a failed follow-up list refresh as a synchronization outcome after a confirmed write, then recover through an awaited read-only refresh. 3 min
  8. Verify the Complete Save and Recovery Flow Verify the completed mutation, cache synchronization, failure recovery, and reload behavior in the local Task Board application. 4 min

What you will learn

  • Connect query reads and mutation writes to one typed in-memory adapter and explain its persistence limits.
  • Submit a form draft through useMutation with pending feedback and duplicate-submission prevention.
  • Refresh affected list and detail queries after a successful update.
  • Choose between invalidating a query and updating its cache from a confirmed mutation result.
  • Exercise a failed write and a failed follow-up refresh, preserving draft input and distinguishing their outcomes.

Before you start

  • Reuse Resource Data Across Routes with TanStack Query
  • Build Controlled Forms in React
  • Send JSON Data with HTTP Requests
  • Sequence Asynchronous Work with async/await

Step 1 of 8

Identify What Must Change After a Save

Trace a Task Board edit from the editable draft to the simulated source and separately cached routed views.

Trace the edit through the application

Three places, three responsibilities

A mutation is a user-triggered write: for example, submitting an edit for t1 from a Task Board form. Its lifecycle is separate from the cached query reads already used by the routed list and detail views.

Keep three responsibilities distinct:

  • A form draft is the editable input. Typing changes only this local draft.
  • The adapter store is the simulated source. A confirmed write changes its task record.
  • The query cache supplies results to mounted routed views. It changes only when the application synchronizes it.

So a Save action is not the same operation as refreshing what the list and detail currently display.

Diagram showing that typing changes a form draft, saving changes the adapter store, and a separate synchronization step updates cached list and detail views.

A confirmed write and cache synchronization are separate steps.

Why confirmation alone is not enough

A save can leave views stale

Imagine the open list remains mounted at /tasks?status=open while its nested selected detail is open at /tasks/t1?status=open.

After an edit is confirmed, the adapter store can contain the new task while the cached open list and cached detail still show earlier query results. A successful write does not automatically update every separately cached result.

This matters especially when an edit changes a task’s status: the task may need to disappear from one filtered list and appear in another. Later steps will add the synchronization behavior that keeps these routed views current.

An updated adapter store is surrounded by a stale cached list and stale cached detail view, connected by an unfinished synchronization path.

The source can be updated before independently cached views are refreshed.

What this local simulation can—and cannot—save

Warning

Know the simulation boundary

A Vite-served JSON file is a read-only HTTP resource from the browser’s perspective. Client-side mutation code can request and use it, but cannot rewrite that file.

For this tutorial, reads and writes will instead use a browser-only in-memory adapter. Its adapter store is useful for practicing the full flow locally, but it is not durable storage: reloading the page restores the seed data. No persistent server record is created.

Tip

Do not infer persistence from a changed screen. In this tutorial, a changed screen can reflect a confirmed write to browser memory and synchronized cached views—but a browser reload removes that simulated change.

Apply the responsibilities

Check the state transitions

Match each event or resource to its outcome.

Tap an item and then its matching pair.

Step 2 of 8

Connect Reads and Writes to One Local Adapter

Replace static task reads with one typed browser-only in-memory adapter, then connect routed list and detail queries to it.

Choose a single local source

One source for every operation

Your routed views must read from the same source that later receives updates. In Task Board, that source is a browser-only in-memory adapter. It owns a module-scoped adapter store: list reads, detail reads, and writes all pass through it.

Do not leave an old Vite static JSON read in either query. A write to an in-memory adapter cannot modify a static JSON resource, so mixing those sources would make routed views disagree.

The adapter store is a local simulation. Reloading the browser recreates its seed data.

Trace the local data flow

Diagram showing one in-memory adapter store supplying both routed reads and receiving a write from a form draft.

Both routed views read from one adapter store; a later editor will write to that same store.

Tip

Work locally

Create the tutorial-specific files under src/features/tasks/ in your own Vite project. Keep the Router and QueryClient providers from the previous tutorial. This step replaces the task data-access path and connects the existing task routes to typed queries.

Create the typed local adapter

src/features/tasks/types.ts

Define the shared contracts first. TaskDraft describes editable fields; UpdateTaskInput adds the identity needed by an update. The adapter returns a complete Task, not only the submitted draft.

types.ts

Create this file.

typescript
export type TaskStatus = 'open' | 'done';
export type TaskFilter = 'all' | 'open' | 'done';

export type Task = {
  id: string;
  title: string;
  status: TaskStatus;
};

export type TaskDraft = Pick<Task, 'title' | 'status'>;
export type UpdateTaskInput = TaskDraft & { id: string };

taskAdapter.ts — setup

Start the adapter module with its module-scoped store, failure configuration, cancellable delay, and copying helper. The 400 ms delay makes local reads and writes observably asynchronous. A copied result prevents a consumer from changing the adapter store through a shared object reference.

typescript
import type {
  Task,
  TaskFilter,
  UpdateTaskInput,
} from './types';

let adapterStore: Task[] = [
  { id: 't1', title: 'Review navigation', status: 'open' },
  { id: 't2', title: 'Update help text', status: 'done' },
];

let demoFailures = { writes: false, reads: false };

export function setDemoFailures(next: {
  writes: boolean;
  reads: boolean;
}) {
  demoFailures = { ...next };
}

function delay(ms: number, signal?: AbortSignal): Promise<void> {
  if (signal?.aborted) {
    return Promise.reject(new DOMException('Read aborted', 'AbortError'));
  }

  return new Promise((resolve, reject) => {
    const abort = () => {
      window.clearTimeout(timer);
      reject(new DOMException('Read aborted', 'AbortError'));
    };
    const timer = window.setTimeout(() => {
      signal?.removeEventListener('abort', abort);
      resolve();
    }, ms);
    signal?.addEventListener('abort', abort, { once: true });
  });
}

function copyTask(task: Task): Task {
  return { ...task };
}

taskAdapter.ts — operations

Append these operations to taskAdapter.ts. Both reads retain the optional AbortSignal. Every fulfilled read returns copies. An update validates its input, trims the title, stores the complete record, and returns another copy. A configured rejected write occurs before any adapter-store assignment.

typescript
export async function getTasks(
  status: TaskFilter,
  signal?: AbortSignal,
): Promise<Task[]> {
  await delay(400, signal);
  if (demoFailures.reads) {
    throw new Error('The in-memory adapter rejected this read.');
  }

  const tasks = status === 'all'
    ? adapterStore
    : adapterStore.filter((task) => task.status === status);

  return tasks.map(copyTask);
}

export async function getTask(
  id: string,
  signal?: AbortSignal,
): Promise<Task> {
  await delay(400, signal);
  if (demoFailures.reads) {
    throw new Error('The in-memory adapter rejected this read.');
  }

  const task = adapterStore.find((entry) => entry.id === id);
  if (!task) {
    throw new Error(`Task ${id} was not found.`);
  }

  return copyTask(task);
}

export async function updateTask(input: UpdateTaskInput): Promise<Task> {
  await delay(400);
  if (demoFailures.writes) {
    throw new Error('The in-memory adapter rejected this write.');
  }
  if (input.title.trim().length === 0) {
    throw new Error('Task title is required.');
  }
  if (input.status !== 'open' && input.status !== 'done') {
    throw new Error('Task status is invalid.');
  }

  const index = adapterStore.findIndex((task) => task.id === input.id);
  if (index === -1) {
    throw new Error(`Task ${input.id} was not found.`);
  }

  const savedTask: Task = {
    ...input,
    title: input.title.trim(),
  };

  adapterStore[index] = savedTask;
  return copyTask(savedTask);
}

Connect the list and local controls

DemoControls.tsx

Add persistent simulation switches to src/features/tasks/DemoControls.tsx. While this component remains mounted in the layout, its switch state remains available while navigating between the list and nested detail. Each change updates the adapter configuration; it does not itself submit or refresh anything.

tsx
import { useState } from 'react';
import { setDemoFailures } from './taskAdapter';

export function DemoControls() {
  const [writes, setWrites] = useState(false);
  const [reads, setReads] = useState(false);

  function changeWrites(nextWrites: boolean) {
    setWrites(nextWrites);
    setDemoFailures({ writes: nextWrites, reads });
  }

  function changeReads(nextReads: boolean) {
    setReads(nextReads);
    setDemoFailures({ writes, reads: nextReads });
  }

  return (
    <fieldset>
      <legend>Local simulation</legend>
      <label>
        <input
          type='checkbox'
          checked={writes}
          onChange={(event) => changeWrites(event.currentTarget.checked)}
        />
        Reject writes
      </label>
      <label>
        <input
          type='checkbox'
          checked={reads}
          onChange={(event) => changeReads(event.currentTarget.checked)}
        />
        Reject reads
      </label>
    </fieldset>
  );
}

taskQueries.ts

Both query hooks import their reads from the adapter. The key factory includes the list prefix needed later; this step only establishes consistent typed reads. Tutorial queries use no automatic retries or window-focus refreshes so local behavior remains reproducible.

typescript
import { useQuery } from '@tanstack/react-query';
import { getTask, getTasks } from './taskAdapter';
import type { TaskFilter } from './types';

export const taskKeys = {
  lists: () => ['tasks', 'list'] as const,
  list: (status: TaskFilter) => ['tasks', 'list', { status }] as const,
  detail: (id: string) => ['tasks', 'detail', id] as const,
};

export function useTasks(status: TaskFilter) {
  return useQuery({
    queryKey: taskKeys.list(status),
    queryFn: ({ signal }) => getTasks(status, signal),
    retry: false,
    refetchOnWindowFocus: false,
  });
}

export function useTask(taskId: string) {
  return useQuery({
    queryKey: taskKeys.detail(taskId),
    queryFn: ({ signal }) => getTask(taskId, signal),
    enabled: taskId.length > 0,
    retry: false,
    refetchOnWindowFocus: false,
  });
}

TaskLayout.tsx

Use the list query and render DemoControls in the parent layout. The nested route renders through Outlet, so this list and the local simulation controls remain mounted when a detail is open. Links preserve the selected status.

tsx
import { Link, Outlet, useSearchParams } from 'react-router-dom';
import { DemoControls } from './DemoControls';
import { useTasks } from './taskQueries';
import type { TaskFilter } from './types';

function readStatus(value: string | null): TaskFilter {
  return value === 'open' || value === 'done' ? value : 'all';
}

export function TaskLayout() {
  const [searchParams] = useSearchParams();
  const status = readStatus(searchParams.get('status'));
  const { data: tasks = [], isPending, isError } = useTasks(status);
  const search = `?status=${status}`;

  return (
    <section>
      <h1>Task Board</h1>
      <DemoControls />
      {isPending && <p>Loading tasks…</p>}
      {isError && <p role='alert'>Could not load tasks.</p>}
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link to={`/tasks/${task.id}${search}`}>{task.title}</Link>
          </li>
        ))}
      </ul>
      <Outlet />
    </section>
  );
}

Connect the nested detail route

TaskDetails.tsx

Use the detail query for the taskId route parameter. The link back to the list preserves the existing search parameters, including status=open. A later step adds the editor after this query has supplied a task.

tsx
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { useTask } from './taskQueries';

export function TaskDetails() {
  const { taskId } = useParams<{ taskId: string }>();
  const [searchParams] = useSearchParams();
  const taskQuery = useTask(taskId ?? '');
  const search = searchParams.toString();
  const listPath = search ? `/tasks?${search}` : '/tasks';

  if (!taskId) return <p role='alert'>Invalid task route.</p>;
  if (taskQuery.isPending) return <p>Loading task…</p>;
  if (taskQuery.isError || !taskQuery.data) {
    return <p role='alert'>Could not load this task.</p>;
  }

  const task = taskQuery.data;
  return (
    <article>
      <h2>{task.title}</h2>
      <p>Status: {task.status}</p>
      <Link to={listPath}>Back to tasks</Link>
    </article>
  );
}

Route integration

Inside your existing Routes component, replace the prior task-route section with this nested route structure. It keeps TaskLayout mounted at /tasks/t1?status=open.

tsx
<Route path='tasks' element={<TaskLayout />}>
  <Route path=':taskId' element={<TaskDetails />} />
</Route>

Read one store through two routes

At /tasks?status=open, getTasks receives open and returns a copied filtered array from the adapter store. Selecting t1 moves to /tasks/t1?status=open; the layout stays mounted while getTask('t1') supplies the selected detail. Both results originate in taskAdapter.ts, not a static JSON file.

Practice the query connections

Connect each routed read

Complete the typed adapter-to-query connections.

typescript
export function useTasks(status: TaskFilter) {
  return useQuery({
    queryKey: ,
    queryFn: ({ signal }) => ,
    retry: false,
    refetchOnWindowFocus: false,
  });
}

export function useTask(taskId: string) {
  return useQuery({
    queryKey: ,
    queryFn: ({ signal }) => ,
    retry: false,
    refetchOnWindowFocus: false,
  });
}

Verify locally

Verify the shared source

In your local app, visit /tasks?status=open, then select t1 to reach /tasks/t1?status=open. Verify the visible seed record or records, then use the Reject reads and Reject writes controls only to confirm that both switches are present. Leave both switches off afterward. Report the titles you observed and the module that both query functions call.

Write at least 25 characters (0/25).

Step 3 of 8

Submit the Draft as a Mutation

Save a typed Task Board form draft through a guarded TanStack Query mutation while keeping the confirmed in-memory write distinct from cached routed views.

Start a write without changing cached reads

One submission, separate state holders

Submitting a form draft starts a mutation: a user-triggered write with a lifecycle separate from cached query reads. The input combines the selected task ID with the current draft.

When updateTask resolves, the adapter store has a confirmed write. That does not yet replace query results already cached for the routed list or detail view. At this point, the editor can truthfully say Saved while those routed views still render their earlier cached data.

A write does not automatically refresh cached views

A form draft is saved to an in-memory adapter store while separate cached list and detail panels still show an older task value.

The mutation writes to the adapter store. Cached routed views remain separate until a later synchronization step.

Create the mutation hook

Create src/features/tasks/useSaveTask.ts for the mutation behavior. mutationFn receives an UpdateTaskInput only when the user submits. retry: false keeps a save intentional: rendering and Effects must not repeat a write.

Connect the confirmed write operation

src/features/tasks/useSaveTask.ts

typescript
import { useMutation } from '@tanstack/react-query';
import { updateTask } from './taskAdapter';

export function useSaveTask() {
  const saveTask = useMutation({
    mutationFn: updateTask,
    retry: false,
  });

  return { saveTask };
}

Guard the controlled form while saving

Keep the editable draft local

Create src/features/tasks/TaskEditor.tsx. Its draft is initialized when this editor mounts. It is not continuously copied from background query data, so typing remains owned by the controlled form.

saveTask.data is named savedTask: it is the confirmed result returned by the adapter. The status feedback describes the mutation outcome only; cache synchronization comes later.

Create the editor, part 1

TaskEditor.tsx — imports, draft, and submit handler

tsx
import { type FormEvent, useState } from 'react';
import type { Task, TaskDraft } from './types';
import { useSaveTask } from './useSaveTask';

type TaskEditorProps = { task: Task };

export function TaskEditor({ task }: TaskEditorProps) {
  const [draft, setDraft] = useState<TaskDraft>({
    title: task.title,
    status: task.status,
  });
  const { saveTask } = useSaveTask();
  const savedTask = saveTask.data;
  const isSaving = saveTask.isPending;

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (isSaving) return;

    saveTask.mutate({ id: task.id, ...draft });
  }

Create the editor, part 2

TaskEditor.tsx — append the returned form and closing brace

tsx
  return (
    <form onSubmit={handleSubmit} aria-busy={isSaving}>
      {isSaving ? (
        <p role='status' aria-live='polite'>Saving…</p>
      ) : saveTask.isError ? (
        <p role='alert'>Could not save. Your draft is unchanged.</p>
      ) : savedTask ? (
        <p role='status' aria-live='polite'>Saved</p>
      ) : null}

      <fieldset disabled={isSaving}>
        <label>
          Title
          <input
            value={draft.title}
            onChange={(event) => setDraft({
              ...draft,
              title: event.currentTarget.value,
            })}
          />
        </label>
        <label>
          Status
          <select
            value={draft.status}
            onChange={(event) => setDraft({
              ...draft,
              status: event.currentTarget.value as TaskDraft['status'],
            })}
          >
            <option value='open'>Open</option>
            <option value='done'>Done</option>
          </select>
        </label>
        <button type='submit'>
          {isSaving ? 'Saving…' : 'Save task'}
        </button>
      </fieldset>
    </form>
  );
}

Tip

One pending state, two protections

The pending guard protects the write even if another submission is triggered before React finishes disabling controls. The disabled fieldset prevents further edits and clicks during the active write, while live status feedback keeps the state available to assistive technology.

Give each selected task its own editor draft

Mount an editor for the selected task

The editor must be rendered inside the available-data branch of TaskDetails. The key changes editor identity only when task.id changes: navigating from one task to another creates a new draft, while a background result for the same task ID does not overwrite the current form draft.

Integrate the editor in the detail route

src/features/tasks/TaskDetails.tsx

tsx
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { TaskEditor } from './TaskEditor';
import { useTask } from './taskQueries';

export function TaskDetails() {
  const { taskId } = useParams<{ taskId: string }>();
  const [searchParams] = useSearchParams();
  const taskQuery = useTask(taskId ?? '');
  const search = searchParams.toString();
  const listPath = search ? `/tasks?${search}` : '/tasks';

  if (!taskId) return <p role='alert'>Invalid task route.</p>;
  if (taskQuery.isPending) return <p>Loading task…</p>;
  if (taskQuery.isError || !taskQuery.data) {
    return <p role='alert'>Could not load this task.</p>;
  }

  const task = taskQuery.data;
  return (
    <article>
      <h2>{task.title}</h2>
      <p>Status: {task.status}</p>
      <TaskEditor key={task.id} task={task} />
      <Link to={listPath}>Back to tasks</Link>
    </article>
  );
}

Tip

Observe the boundary

Try a title with leading or trailing spaces in your local Task Board. The successful adapter operation returns a complete savedTask with its title trimmed. The editor can report the confirmed write, but the list heading and detail heading still come from their existing cached query results for now.

Apply the mutation boundary

Complete the guarded mutation

Complete the mutation configuration and the guarded user submission.

tsx
const saveTask = useMutation({
  mutationFn: ,
  retry: false,
});

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  if (saveTask.) return;

  saveTask.({ id: task.id, ...draft });
}

Explain the temporary mismatch

After completing the code, explain in two or three sentences why a successful save can leave the adapter store with the saved task while the routed list or detail still shows its earlier value. Include the distinct responsibilities of the mutation and the query cache.

Write at least 110 characters (0/110).

Step 4 of 8

Invalidate the Views Affected by the Update

Refresh all affected cached task views after a confirmed update by invalidating list variants and the edited detail entry.

Map the affected cache entries

One write, several cached views

A confirmed write changes the adapter store. It does not automatically change every cached query that previously read that task.

For t1, a status change can affect two kinds of routed views:

  • the exact detail entry for /tasks/t1?status=open
  • every cached task-list variant, because membership may change between open, done, and all

In TaskLayout, the filtered list stays mounted while its nested detail route is open. That makes both views active candidates for a follow-up refresh.

A confirmed update to task t1 points to cached open and done lists plus its individual detail entry.

A status change can alter filtered-list membership, so refreshing only the detail is not enough.

Synchronize after the confirmed write

Invalidate broadly for lists, precisely for detail

Put synchronization in the mutation hook's hook-level onSuccess callback. It receives savedTask only after updateTask confirms the write. Use that confirmed ID rather than relying on route state or the submitted draft.

taskKeys.lists() is a prefix: it matches cached lists for every status. The detail key uses exact: true so it targets only the one edited task.

Await the follow-up refresh work

src/features/tasks/useSaveTask.ts

tsx
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateTask } from './taskAdapter'
import { taskKeys } from './taskQueries'

export type SyncStatus = 'idle' | 'refreshing' | 'current' | 'failed'

export function useSaveTask() {
  const queryClient = useQueryClient()
  const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')

  const saveTask = useMutation({
    mutationFn: updateTask,
    retry: false,
    onMutate: () => setSyncStatus('idle'),
    onSuccess: async (savedTask) => {
      setSyncStatus('refreshing')

      await Promise.all([
        queryClient.invalidateQueries({
          queryKey: taskKeys.lists(),
        }),
        queryClient.invalidateQueries({
          queryKey: taskKeys.detail(savedTask.id),
          exact: true,
        }),
      ])

      setSyncStatus('current')
    },
  })

  return { saveTask, syncStatus }
}

Make the two pending phases visible

TaskEditor status and submission controls

tsx
const { saveTask, syncStatus } = useSaveTask()
const isRefreshing = saveTask.isPending && syncStatus === 'refreshing'

<p aria-live="polite">
  {saveTask.isPending
    ? isRefreshing
      ? 'Saved; refreshing views…'
      : 'Saving…'
    : syncStatus === 'current'
      ? 'Saved'
      : null}
</p>

<button type="submit" disabled={saveTask.isPending}>
  Save task
</button>

Understand what invalidation changes

Stale now; refetch timing depends on use

Invalidation marks matching cache entries stale. It does not erase their useful data.

Because the open list and t1 detail are mounted, they normally begin refetching immediately. A cached done list that is not mounted is marked stale now and normally refreshes when a view starts using it later.

Tip

Why await invalidation?

The await keeps saveTask.isPending true during the chosen follow-up synchronization. Therefore the interface can move from Saving… to Saved; refreshing views… before it reaches Saved.

Warning

Scope of this implementation

This first version uses TanStack Query's default non-throwing refetch-error behavior for invalidation. A read refresh problem is not yet handled as a separate synchronization outcome; the next steps refine that behavior without treating it as a failed write.

Cache target

How the two invalidations differ.
TargetKey strategyExpected result
All filtered lists`taskKeys.lists()` prefixEvery cached status variant becomes stale
Edited task detailExact detail key for confirmed IDOnly that task's detail becomes stale
Active matchSame invalidationNormally refetches immediately
Inactive matchSame invalidationNormally refetches when used later

Apply the invalidation rule

Choose the synchronization plan

t1 changes from open to done. The open list and its detail are currently mounted; a done-list query is cached but inactive. Which onSuccess strategy correctly synchronizes the affected views?

Select the correct plan.

Step 5 of 8

Use Confirmed Results Where They Are Sufficient

Use a confirmed mutation result to update the exact detail cache safely, while invalidating broader list views that need a refresh.

Use evidence at the right scope

A confirmed result is specific evidence

A confirmed write gives you a complete saved record from the adapter store. That record can immediately replace one cache entry when it fully describes that entry: the detail query for that same task.

This is not an optimistic update: the cache changes only after updateTask resolves. It is also not persistence: setQueryData changes TanStack Query's browser cache, not the adapter store.

For Task Board, use savedTask, not the submitted form draft. The adapter normalizes a successful title by trimming it, so the draft might not equal the confirmed record.

One record, two cache decisions

A confirmed task record directly replaces its matching detail cache entry, while multiple filtered list cache entries are refreshed.

A complete confirmed record is enough for its exact detail entry; affected lists still need invalidation.

Replace detail, invalidate lists

Compare the synchronization choices

Choose the cache operation by what the confirmed result can safely describe. The taskKeys.lists() prefix matches every cached list variant, including open, done, and all.

Do not manually patch each list variant here. A task's fields alone may not establish list membership rules, ordering, counts, or pagination.

Cache operations by scope

Use the confirmed result for the exact detail entry and invalidate the broader list family.

The final strategy combines a precise replacement with a broad refresh.
OperationBest useWhy
Invalidate a detail queryDetail result is not sufficientFetches current detail data later or now if active
setQueryData for detailComplete confirmed Task for that IDShows normalized saved data immediately
Invalidate list prefixAny update can affect listsRefreshes every relevant filter variant
Combine bothTask Board final strategyPrecise detail plus safe list membership refresh

Synchronize after the confirmed write

Replace the existing successful-sync work in src/features/tasks/useSaveTask.ts. Keep the existing syncStatus transitions around this awaited callback.

tsx
onSuccess: async (savedTask) => {
  await queryClient.cancelQueries({
    queryKey: taskKeys.detail(savedTask.id),
    exact: true,
  })

  queryClient.setQueryData(
    taskKeys.detail(savedTask.id),
    savedTask,
  )

  await queryClient.invalidateQueries({
    queryKey: taskKeys.lists(),
  })
},

Protect the confirmed detail from an older read

Apply the exact-detail update

Complete the cache method that writes the confirmed record into only the matching detail entry. The cancellation stays first so an older in-flight detail read cannot overwrite that confirmed result.

Write the confirmed detail cache entry

Fill in the QueryClient method.

tsx
await queryClient.cancelQueries({
  queryKey: taskKeys.detail(savedTask.id),
  exact: true,
})

queryClient.(
  taskKeys.detail(savedTask.id),
  savedTask,
)

await queryClient.invalidateQueries({
  queryKey: taskKeys.lists(),
})

Decide from the result's scope

Choose the final cache strategy

A mutation confirms a complete normalized Task for t1. Its status may have changed, and Task Board has cached multiple filtered list variants. Which synchronization policy is appropriate?

Step 6 of 8

Recover from a Rejected Write Without Losing Input

Preserve an editable form draft and confirmed routed data when the local in-memory adapter rejects a write, then recover through one deliberate resubmission.

Keep the draft separate from confirmed data

A rejection preserves the current state

A rejected write must stay rejected. In this simulation, updateTask throws before it changes the adapter store, so useMutation enters its error state rather than receiving misleading success data.

After that rejection, three things have separate outcomes:

  • The form draft remains mounted, local, and editable.
  • The adapter store remains as it was before submission.
  • Previously confirmed query-cache data remains unchanged because success-only synchronization does not run.

Do not clear the draft, copy an older query result over it, or navigate away after a write error.

What remains after a rejected write

A filled form returns from a blocked write gate while the adapter store and cached routed views remain unchanged.

The form draft survives the rejection; no confirmed data is replaced.

Warning

Simulation boundary

This browser-only simulation rejects before committing, making the exercise deterministic: a rejected write leaves its adapter store unchanged.

A real transport failure can be ambiguous. A request may fail after a remote system has already processed it. Do not generalize this simulation into a guarantee about every real-world write.

Expose the write error and retain input

Reject before committing the update

In src/features/tasks/taskAdapter.ts, keep the configured rejection before validation, record creation, and adapter-store assignment. The earlier delay helper remains in this module.

typescript
export async function updateTask(input: UpdateTaskInput): Promise<Task> {
  await delay(400);

  if (demoFailures.writes) {
    throw new Error('The in-memory adapter rejected this write.');
  }

  const title = input.title.trim();
  if (title.length === 0) {
    throw new Error('Task title is required.');
  }
  if (input.status !== 'open' && input.status !== 'done') {
    throw new Error('Task status is invalid.');
  }

  const index = adapterStore.findIndex((task) => task.id === input.id);
  if (index === -1) {
    throw new Error(`Task ${input.id} was not found.`);
  }

  const savedTask: Task = {
    ...input,
    title,
  };

  adapterStore[index] = savedTask;
  return copyTask(savedTask);
}

Show the error without replacing the draft

A write-specific error is shown only when saveTask.isError is true. Replace the feedback area in the existing TaskEditor.tsx form with this branch. Keep the existing controlled inputs inside fieldset disabled={isSaving}: when the pending write ends in rejection, that fieldset becomes enabled again and still reads from the unchanged draft state.

Keep the editor mounted and editable

TaskEditor.tsx — feedback branch

tsx
{saveTask.isError ? (
  <p role='alert'>Could not save. Your draft is unchanged.</p>
) : saveTask.isPending ? (
  <p role='status' aria-live='polite'>
    {syncStatus === 'refreshing'
      ? 'Saved; refreshing views…'
      : 'Saving…'}
  </p>
) : syncStatus === 'current' ? (
  <p role='status' aria-live='polite'>Saved</p>
) : null}

<fieldset disabled={isSaving}>
  {/* Keep the existing controlled title and status controls here. */}
  <button type='submit'>
    {isSaving ? 'Saving…' : 'Save task'}
  </button>
</fieldset>

Tip

Let the mutation expose the failure

Do not catch the adapter rejection and return a fallback task. A fulfilled fallback would incorrectly run onSuccess even though no confirmed write occurred.

Keep retry: false. After disabling Reject writes, recovery is one new, deliberate user submission—not an Effect, a timer, or an automatic retry.

Check the rejection path

Protect the adapter-store commit

Complete the safeguards that make a configured failed write leave the adapter store unchanged while successful titles are normalized.

typescript
export async function updateTask(input: UpdateTaskInput): Promise<Task> {
  await delay(400);

  if (demoFailures.) {
     new Error('The in-memory adapter rejected this write.');
  }

  const title = input.title.trim();
  if (title.length === 0) {
    throw new Error('Task title is required.');
  }

  const index = adapterStore.findIndex((task) => task.id === input.id);
  if (index === -1) {
    throw new Error(`Task ${input.id} was not found.`);
  }

  const savedTask: Task = {
    ...input,
    title: ,
  };

  adapterStore[index] = savedTask;
  return copyTask(savedTask);
}

Practice intentional recovery

Run one rejected write, then recover

In your local Task Board, reload the browser to restore the seed data. Ensure Reject reads and Reject writes are off, then open /tasks/t1?status=open so the open list stays mounted with the selected detail.

Change only t1’s title in the form draft to Review navigation labels. Enable Reject writes and select Save task once. After the error appears, verify that the title remains editable in the form draft and that the routed heading and open-list entry still show their previously confirmed value.

Next, disable Reject writes and select Save task exactly once. Report the rejection observations and the outcome of that deliberate resubmission.

Write at least 220 characters (0/220).

Step 7 of 8

Separate Save Success from Refresh Failure

Handle a failed follow-up list refresh as a synchronization outcome after a confirmed write, then recover through an awaited read-only refresh.

Distinguish the write from its synchronization

Two outcomes after one submission

A confirmed write and a follow-up refresh are separate outcomes. When updateTask resolves, the adapter store has accepted the edit and savedTask is confirmed. If the mounted list then cannot read, that is a synchronization failure—not evidence that the write failed.

Keep the confirmed detail entry and any previously displayed list data. Mark the list as needing recovery instead of asking the user to submit the form draft again. Repeating a write would be a new operation, not a repair for a rejected read.

Confirmed write versus failed follow-up refresh

Diagram showing a confirmed form write updating a task detail while a separate list refresh fails.

The confirmed detail can remain current while the previously available list needs a read-only refresh.

Tip

Make the refresh outcome observable

invalidateQueries normally records refetch errors in query state without rejecting its returned promise. To detect a failed follow-up refresh, use throwOnError: true in the second options argument of the TanStack Query v5 call.

Catch that rejection during the asynchronous success path. The mutation then remains a successful write, while syncStatus records whether the follow-up refresh made views current or failed.

Implement the caught refresh path

Catch refresh errors without reclassifying the write

Replace the body of useSaveTask in src/features/tasks/useSaveTask.ts with the version below. Keep the existing SyncStatus type from the earlier step. The exact detail is cancelled and replaced from savedTask before the list family is refreshed.

The try/catch is inside onSuccess, so a rejected list read sets syncStatus to 'failed' without entering the mutation's write-error path. refreshViews repeats only the list invalidation; it never calls mutate or updateTask.

Keep write success separate from read recovery

useSaveTask.ts — replace the hook implementation

tsx
export function useSaveTask() {
  const queryClient = useQueryClient();
  const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');

  async function invalidateLists() {
    await queryClient.invalidateQueries(
      { queryKey: taskKeys.lists() },
      { throwOnError: true },
    );
  }

  async function refreshViews() {
    setSyncStatus('refreshing');
    try {
      await invalidateLists();
      setSyncStatus('current');
    } catch {
      setSyncStatus('failed');
    }
  }

  const saveTask = useMutation({
    mutationFn: updateTask,
    retry: false,
    onMutate: () => setSyncStatus('idle'),
    onSuccess: async (savedTask) => {
      await queryClient.cancelQueries({
        queryKey: taskKeys.detail(savedTask.id),
        exact: true,
      });
      queryClient.setQueryData(
        taskKeys.detail(savedTask.id),
        savedTask,
      );

      setSyncStatus('refreshing');
      try {
        await invalidateLists();
        setSyncStatus('current');
      } catch {
        setSyncStatus('failed');
      }
    },
  });

  return { saveTask, syncStatus, refreshViews };
}

Tip

Keep confirmed data visible

Why the detail remains usable: savedTask is a complete confirmed record for taskKeys.detail(savedTask.id), so that exact cached detail can remain current even when a later list read fails. The list cache keeps any data it already had; its query also records the read failure. The editor warning makes the synchronization problem explicit.

Report and recover in the interface

Expose the failed-refresh state in the editor

Update the existing TaskEditor.tsx values, submit guard, and feedback area as shown. Keep the controlled title and status inputs already inside this fieldset.

During either automatic or manual refreshing, editing and submitting are disabled. After a failed refresh, the only recovery control is Refresh views. Its handler awaits the read-only action, so it cannot repeat the confirmed write.

Show the correct recovery action

TaskEditor.tsx — update the hook values, handlers, and form feedback

tsx
const { saveTask, syncStatus, refreshViews } = useSaveTask();
const savedTask = saveTask.data;
const isSaving = saveTask.isPending;
const isRefreshing = syncStatus === 'refreshing';

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
  if (isSaving || isRefreshing) return;

  saveTask.mutate({ id: task.id, ...draft });
}

async function handleRefresh() {
  await refreshViews();
}

{saveTask.isError ? (
  <p role='alert'>Could not save. Your draft is unchanged.</p>
) : isSaving || isRefreshing ? (
  <p role='status' aria-live='polite'>
    {isSaving && syncStatus !== 'refreshing'
      ? 'Saving…'
      : 'Saved; refreshing views…'}
  </p>
) : syncStatus === 'failed' ? (
  <>
    <p role='alert'>Saved, but refresh failed.</p>
    <button type='button' onClick={handleRefresh}>
      Refresh views
    </button>
  </>
) : syncStatus === 'current' || savedTask ? (
  <p role='status' aria-live='polite'>Saved</p>
) : null}

<fieldset disabled={isSaving || isRefreshing}>
  {/* Keep the existing controlled title and status controls here. */}
  <button type='submit'>
    {isSaving ? 'Saving…' : 'Save task'}
  </button>
</fieldset>

Example

Run the read-only recovery

To exercise this path locally, open /tasks/t1?status=open with the list still mounted. Leave Reject writes off, enable Reject reads, edit and save t1 once, and wait for the warning. The detail shows the confirmed result from the adapter store; the list retains its previous data but could not complete its follow-up refresh. Turn Reject reads off, then select Refresh views—not Save task.

Choose recovery without repeating the write

Classify the three outcomes

Match each Task Board outcome to its correct interface response and next action.

Tap an item and then its matching pair.

Step 8 of 8

Verify the Complete Save and Recovery Flow

Verify the completed mutation, cache synchronization, failure recovery, and reload behavior in the local Task Board application.

Successful save: check both views

1. Verify the successful routed update

Run the final checks in your own Task Board. For a repeatable run, reload the browser first and set both Reject writes and Reject reads to off.

Open /tasks?status=open and keep that list mounted while viewing /tasks/t1?status=open. Edit t1 to Review keyboard navigation with status done, then save once. Observe the pending feedback, followed by the completed synchronization state.

Confirm two routed views: the selected detail shows the confirmed title and status, while the open list no longer contains t1. Then navigate to the done filter and confirm that t1 appears there. Preserve the status search parameter as you move between list and detail routes.

The expected result after synchronization

A task detail is updated to done while the open-task list no longer includes that task.

After the confirmed write and follow-up refresh, the detail is current and the open list excludes the task whose status changed.

Failure drills and the final rule

2. Exercise recovery paths

Use these drills after completing the successful update. They distinguish a rejected write from a confirmed write whose later read synchronization fails. Keep the list mounted during the failed-refresh case.

Recovery verification checklist

Perform each case deliberately in the local app.

The rejected-write recovery is a new intentional submission; refresh recovery is read-only.
ScenarioSetup and actionEvidence to check
Rejected writeTurn on Reject writes. Enter a different valid form draft and save. Turn Reject writes off, then resubmit the preserved draft.The write error appears, the draft remains editable, and confirmed detail and list data stay unchanged until the intentional resubmission succeeds.
Failed follow-up refreshKeep Reject writes off and turn on Reject reads. Change the draft and save. Turn Reject reads off, then choose Refresh views.The save is confirmed, the detail remains confirmed, and the list is marked stale until read-only recovery succeeds.
Inactive list variantAfter recovery, visit the done filter, then return to the open filter and its selected detail route.The done variant includes t1 and the open variant excludes it; an inactive variant becomes current when it is used.
Reload boundaryReload the browser after recording the successful result.The two seed records return because the in-memory adapter store is recreated; no static file or durable record changed.

Tip

Recovery boundary

Do not select Save task again merely because a follow-up refresh failed. The write has already been confirmed. Use Refresh views, which repeats synchronization reads without calling the mutation.

Summary

Decision rule

  • Submit a form draft only for an intentional write.
  • Use a confirmed result for the exact detail entry it fully describes.
  • Invalidate affected list variants because membership can change.
  • Recover a rejected write by preserving the draft and intentionally resubmitting.
  • Recover a failed follow-up refresh with read-only Refresh views.
  • A browser reload recreates the in-memory adapter store from its seed records.

Integrated callback check

3. Complete the synchronization callback

Complete the three QueryClient method names in this final onSuccess callback.

tsx
onSuccess: async (savedTask) => {
  await queryClient.({
    queryKey: taskKeys.detail(savedTask.id),
    exact: true,
  });

  queryClient.(
    taskKeys.detail(savedTask.id),
    savedTask,
  );
  setSyncStatus('refreshing');

  try {
    await queryClient.(
      { queryKey: taskKeys.lists() },
      { throwOnError: true },
    );
    setSyncStatus('current');
  } catch {
    setSyncStatus('failed');
  }
},

Apply and report the complete flow

Final local verification report

After completing the callback check, run the local scenarios and report your observations in five labeled parts:

  1. Callback: what the completed callback does for the confirmed detail and affected lists.
  2. Success: what happened to t1 in the detail, open list, and done list after the requested edit.
  3. Rejected write: what happened to the form draft and confirmed data, and how you recovered.
  4. Failed refresh: what was confirmed, what remained stale, and why Refresh views performs only reads.
  5. Reload: what happened after reloading and why the simulated change disappeared.

Describe behavior you observed in your own local app; no file upload is needed.

Write at least 400 characters (0/400).

Tutorial complete

Congratulations! You completed: Synchronize Cached Data After Mutations

You completed the save-and-recovery verification flow in your local Task Board.

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