Deriving Filtered Views From a Normalized Cache

A dashboard shows one cached list three ways: all tasks, only the current user’s open tasks, and tasks grouped by project. The temptation is to fire three queries — but that triples the network traffic and, worse, creates three cache entries for data that is really one list, so a single mutation now has to update three slots to stay consistent. The correct approach derives each view in memory from one shared cache entry using the React Query v5 select option. This page is a focused recipe within Selector & Derivation Patterns; if you have not yet shaped the underlying store so a list is cheap to filter and join, start with How to Design a Normalized State Tree.

The payoff is twofold: no duplicate cache entries, because every view reads the same canonical array, and no collateral re-renders, because select is per-observer and the filtered output is memoized against its own previous result. The same query-key discipline that keeps a single authoritative entry addressable — covered across Data Normalization & Query Key Design — is what makes one cached list safe to project many ways.

Diagnostic Checklist

Before writing a selector, confirm your symptoms match this failure mode:

  • You added a second useQuery with an endpoint like /tasks?status=open purely to get a filtered shape you could have derived on the client.
  • A mutation has to setQueryData on several query keys that all hold slices of the same list, and they drift out of sync.
  • Filtering in the component body rebuilds a new array every render, and React DevTools shows the list re-rendering when an unrelated input changes.
  • An unfiltered list component re-renders whenever a sibling component that filters the same query re-renders.
  • data.length in your filtered view never matches what the cache actually holds because the filter runs against a stale local copy.

One cache entry projected into three filtered views A single query cache entry holding a tasks array fans out to three observers, each with its own select transform: all tasks, open tasks for the current user, and tasks grouped by project. No observer creates a new cache entry or network request. Query Cache queryKey: ['tasks'] tasks: Task[] (one entry) Observer A select: identity all tasks Observer B select: filter + sort my open tasks Observer C select: group-by tasks by project one network request · one cache entry · three memoized projections a change to B's filter never notifies A or C
Each observer derives its own view from the shared entry; per-observer memoization isolates them from one another.

Step-by-Step Implementation

Step 1 — Cache the canonical list once

Define one query for the full list. Every view will derive from this single entry, so there is exactly one network request and one cache slot to keep consistent on mutation.

import { useQuery } from '@tanstack/react-query';

export interface Task {
  id: string;
  title: string;
  assigneeId: string;
  status: 'open' | 'in_progress' | 'done';
  priority: number;
}

async function fetchTasks(): Promise<Task[]> {
  const res = await fetch('/api/tasks');
  if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
  return res.json() as Promise<Task[]>;
}

// The unfiltered view: no select, returns the canonical array.
export function useAllTasks() {
  return useQuery({
    queryKey: ['tasks'],
    queryFn: fetchTasks,
    staleTime: 30_000,
    gcTime: 5 * 60_000,
    structuralSharing: true,
  });
}

Cache Behavior Analysis. With structuralSharing: true (the default), a background refetch that returns deep-equal data hands back the exact same Task[] reference, so useAllTasks consumers never re-render on a no-op poll. This canonical entry is the single source every filtered view reads from — there is no second queryKey, so no duplicate cache slot can drift.

Step 2 — Add a per-component select that filters and sorts

A second component wants only the current user’s open tasks, highest priority first. It reuses the same queryKey and adds a select transform. Because select runs per observer, this projection is invisible to useAllTasks.

import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { fetchTasks, type Task } from './use-all-tasks';

export function useMyOpenTasks(userId: string) {
  // Stabilise the selector against userId so React Query can memoize it.
  const select = useCallback(
    (tasks: Task[]): Task[] =>
      tasks
        .filter((t) => t.assigneeId === userId && t.status === 'open')
        .sort((a, b) => b.priority - a.priority),
    [userId],
  );

  return useQuery({
    queryKey: ['tasks'],
    queryFn: fetchTasks,
    select,
    staleTime: 30_000,
    structuralSharing: true,
  });
}

Cache Behavior Analysis. select runs per observer, so this hook derives my open tasks from the same cached array that useAllTasks reads — no extra fetch, no extra cache entry. React Query memoizes the select output: it re-invokes the transform only when the cached array reference changes or when userId changes the selector identity, then applies structuralSharing to the returned array so that if the filtered result is deep-equal to the previous one, the prior reference is reused and the component skips its render.

Step 3 — Stabilise the selector and derive a grouped view

The third view groups tasks by project. Grouping allocates a fresh object, so keeping the selector identity stable is what prevents needless recomputation. Hoist any prop-free transform to module scope; wrap prop-dependent ones in useCallback.

import { useQuery } from '@tanstack/react-query';
import { fetchTasks, type Task } from './use-all-tasks';

type TasksByStatus = Record<Task['status'], Task[]>;

// Prop-free transform — hoisted for a stable identity.
function groupByStatus(tasks: Task[]): TasksByStatus {
  const base: TasksByStatus = { open: [], in_progress: [], done: [] };
  return tasks.reduce((acc, task) => {
    acc[task.status].push(task);
    return acc;
  }, base);
}

export function useTasksByStatus() {
  return useQuery({
    queryKey: ['tasks'],
    queryFn: fetchTasks,
    select: groupByStatus,
    staleTime: 30_000,
    structuralSharing: true,
    notifyOnChangeProps: ['data'],
  });
}

Cache Behavior Analysis. The hoisted groupByStatus has a constant identity, so React Query never mistakes it for a changed transform and re-runs the group-by only when the underlying array changes. structuralSharing still applies to the returned object: unchanged status buckets keep their references, so a consumer rendering only the done column does not re-render when an open task is added. notifyOnChangeProps: ['data'] further isolates the view — background isFetching toggles never wake it, only a genuine change to the derived data does.


Edge Cases and Gotchas

The filter term changes on every keystroke

When the filter predicate depends on live input, the select closure must capture the current term, which means it legitimately re-runs as the user types. That is expected. The bug to avoid is an inline select={(t) => t.filter(...)} that is redefined every render regardless of the term — hoist it into a useCallback keyed on the term so React Query only re-runs the transform when the term actually changes, and let structuralSharing collapse identical filtered output back to the previous reference.

A mutation must update the one canonical entry

Because every view derives from ['tasks'], a mutation only patches that single entry — never the derived views, which recompute automatically.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { Task } from './use-all-tasks';

export function useCompleteTask() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: string) =>
      fetch(`/api/tasks/${id}/complete`, { method: 'POST' }).then((r) => r.json()),
    onSuccess: (_data, id) => {
      queryClient.setQueryData<Task[]>(['tasks'], (old) =>
        old?.map((t) => (t.id === id ? { ...t, status: 'done' } : t)),
      );
    },
  });
}

Patching the single ['tasks'] entry lets every select re-derive on the next read; there is no second slot to keep in sync, which is the whole reason to derive rather than duplicate.

Empty results should stay referentially stable

A filter that matches nothing returns a new [] on each call, and two empty arrays are not the same reference. structuralSharing treats deep-equal empty arrays as unchanged and reuses the prior reference, so an empty filtered view does not re-render on every poll — but only if you have not disabled structural sharing on the query.


Common Pitfalls and Resolutions

Observable Issue Root Cause Diagnostic Resolution
A filtered view triggers an extra network request A second useQuery with a filtered endpoint was added instead of a select over the existing entry Delete the second query; reuse queryKey: ['tasks'] and derive the shape in select so one fetch serves every view
The filtered list re-renders on unrelated state changes select is defined inline, so React Query sees a new transform identity each render and re-runs it Wrap the selector in useCallback keyed on its real dependencies, or hoist it if it has none
An unfiltered list re-renders when a sibling filters the same query Assumed select is shared across observers; it is not the cause — the real issue is the unfiltered query lost referential stability Confirm structuralSharing is on so the canonical array reference is stable across refetches; each observer’s select is independent by design
Grouped view re-renders every bucket when one item changes The group-by rebuilds all buckets with new references each call Keep structuralSharing enabled so unchanged buckets reuse references, and render each bucket in a memoized child component

Frequently Asked Questions

Does adding a filtered select create a second cache entry or network request?

No. A select transform is a client-side projection applied per observer over the existing cache entry for that queryKey. There is no additional cache slot and no extra fetch — the filtered view is derived in memory from the same cached array that every subscriber shares. That is precisely why deriving beats adding a second query: one entry, one mutation target, many views.

Why does my filtered list re-render on every keystroke in the search box?

If the filter term lives in component state and the select closure captures it, React Query re-runs select whenever the term changes — which is correct behavior. The re-render storm usually comes from the select function being redefined inline each render, or from returning a new array reference even when the filtered result is identical. Wrap the selector in useCallback keyed on the term and keep structuralSharing on so unchanged output collapses back to the previous reference.

How do I keep an unfiltered list from re-rendering when another component filters the same query?

select runs per observer, so a filtered view in one component cannot affect the output another component derives. The unfiltered subscriber keeps returning the same structurally shared array reference across refetches, so as long as its own select (or absence of one) is stable, it never re-renders because of the sibling’s filter. If it does re-render, the cause is elsewhere — usually a lost reference from a disabled structuralSharing or an inline transform.