rosfandy.me
frontend

Stop Refetching Everything

My devs experience on boosting UI performance issues.

2026-09-053 min readrosfandy
#performance#issues
Share

TL;DR

One small mutation can triggered unnecessary API requests in React and Tanstack/React-Query

The Discovery

The UI looked correct, but one mutation produced multiple requests:

Event:

  • PUT /graphs/task-id

Mutation Request:

  • GET /graphs/list-id
  • GET /graphs/workspace-id/subgraph
  • GET /graphs/workspace-id?relation=has_status_template
  • GET /graphs/workspace-id?relation=has_tags
  • GET /graphs/workspace-id?relation=has_priorities

Create, update, and delete all showed the same pattern. Every mutation called the same invalidation function:

tsx
const invalidateGraphQueries = () => {
  queryClient.invalidateQueries({ queryKey: ["graphNode"] });
  queryClient.invalidateQueries({ queryKey: ["workspace-hierarchy"] });
  queryClient.invalidateQueries({ queryKey: ["workspace-statuses"] });
  queryClient.invalidateQueries({ queryKey: ["workspace-tags"] });
  queryClient.invalidateQueries({ queryKey: ["workspace-priorities"]});
};

It was safe because the UI always received fresh data, but it was also wasteful. Updating one task did not change the hierarchy, tags, or priorities.

Why It Happened

React Query uses prefix matching:

tsx
queryClient.invalidateQueries({
  queryKey: ["graphNode"],
});

This can match all queries beginning with graphNode:

tsx
["graphNode", "task-list"]
["graphNode", "task-id"]
["graphNode", "document-id"]

Our requests also used a timestamp:

tsx
graphService.getGraph(id, {
  _t: Date.now(),
});

Because the URL changed every time, each invalidation resulted in a real network request.

Solution

Adding each mutation to declare what it changed:

tsx
type GraphInvalidateScope =
  | "nodes"
  | "hierarchy"
  | "statuses"
  | "tags"
  | "priorities"
  | "taskLogs";

A task update now invalidates only node queries:

tsx
updateGraph(taskId, data, message, {
  workspaceId,
  scope: ["nodes"],
});

A tag update invalidates only tags:

tsx
updateGraph(tagId, data, message, {
  workspaceId,
  scope: ["tags"],
});

The shared helper handles those scopes:

tsx
const invalidateByScope = (
  scope: GraphInvalidateScope[],
  workspaceId?: string,
) => {
  if (scope.includes("nodes")) {
    queryClient.invalidateQueries({
      queryKey: ["graphNode"],
    });
  }

  if (scope.includes("tags")) {
    queryClient.invalidateQueries({
      queryKey: ["workspace-tags", workspaceId],
    });
  }

  if (scope.includes("statuses")) {
    queryClient.invalidateQueries({
      queryKey: ["workspace-statuses", workspaceId],
    });
  }
};

Result

Metric Before After
Refetches triggered per mutation 5–7 1
Invalidation strategy Broad Targeted
Mutation context Limited Sufficient to identify affected data
Network overhead High Lower

Comments

Loading comments...