Selector & Derivation Patterns
Almost every screen renders a shape that does not exist in the cache. The server returns a flat array of orders; the component needs the three most recent, grouped by status, with a formatted total. The naive fix — compute that shape inline in the component body — reconstructs a new array on every render and forces React to reconcile the whole subtree even when nothing the user can see has changed. Selector and derivation patterns move that projection to a place where it can be memoized once and shared, so the cache stays the single source of truth and the component only wakes when its derived slice actually changes. This work sits directly under State Architecture & Cache Fundamentals and is the read-side counterpart to the storage decisions covered in Reference vs Value Storage Models.
The core tools are narrow and specific: the React Query v5 select option (a per-observer transform that is memoized against its own previous output), useMemo boundaries for prop-dependent derivations, reselect-style memoized selectors for expensive shared computations, and Apollo InMemoryCache field read functions for values that belong to the entity itself. This guide shows when each applies, and — critically — how each one preserves object identity so unrelated subscribers are never dragged into a re-render.
Diagnostic Checklist
You are looking at a selector or derivation problem if you observe:
- A list re-renders in full every time an unrelated field on one entity changes, even though the visible rows are identical.
- React DevTools “Highlight Updates” flashes a component whose displayed values never changed, on every parent render.
- A derived total, count, or grouping is recomputed on every keystroke in an unrelated input on the same screen.
- Two components read the same
queryKeybut you added a seconduseQuerywith a different endpoint to get a filtered shape, duplicating the network request. - Passing
datafromuseQueryinto auseMemostill produces a new reference every render becausedataitself is a fresh object from an over-eager transform. - An Apollo query that requests a computed field (like
fullName) either returnsundefinedor forces a network round-trip because the field was never resolved locally.
Prerequisites
Before applying the patterns below, make sure you are comfortable with:
- Referential stability and structural sharing: understand why React Query hands back the same object reference when deep-equal data returns, and how that lets React skip reconciliation — covered in depth in Structural Sharing and Referential Stability.
- The reference-vs-value distinction: know when the cache stores a normalized reference versus an inlined value, since a
selecttransform can only reuse identity for the parts of the tree the cache itself kept stable — see Reference vs Value Storage Models. - React Query v5
useQuerymechanics: familiarity withqueryKey,queryFn,staleTime,gcTime, and the observer model where eachuseQuerycall is an independent subscriber.
Implementation 1 — Per-Observer Projection with select
The select option on useQuery is the first tool to reach for, because it does two things at once: it transforms the cached data into the shape the component needs, and it narrows what the component subscribes to. When select returns only a derived slice, React Query compares that slice against the previous output — not the whole query — before deciding whether to notify the observer. A component that reads a computed count no longer wakes when an unrelated field on the underlying array changes.
Steps:
- Define the cached query once with its canonical
queryKeyandqueryFn, returning the raw server shape. - Add a
selecttransform that projects the raw data into the view model the component actually renders. - Hoist the transform to a stable module-level function (or wrap it with
useCallback) so React Query does not see a new function identity each render. - Return primitives or memoized objects from
selectso structural sharing can preserve the top-level reference when the derived value is unchanged.
import { useQuery } from '@tanstack/react-query';
interface Order {
id: string;
status: 'pending' | 'shipped' | 'delivered';
total: number;
placedAt: string;
}
interface OrderSummary {
openCount: number;
revenue: number;
}
async function fetchOrders(): Promise<Order[]> {
const res = await fetch('/api/orders');
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.json() as Promise<Order[]>;
}
// Hoisted transform — stable identity across renders.
function toSummary(orders: Order[]): OrderSummary {
return {
openCount: orders.filter((o) => o.status !== 'delivered').length,
revenue: orders.reduce((sum, o) => sum + o.total, 0),
};
}
export function useOrderSummary() {
return useQuery({
queryKey: ['orders'],
queryFn: fetchOrders,
select: toSummary,
staleTime: 30_000,
gcTime: 5 * 60_000,
});
}
Cache Behavior Impact. select runs per observer, so this hook and any other useQuery(['orders']) elsewhere share one network request but derive independently. React Query memoizes the select result: it re-invokes toSummary only when the underlying cached array changes reference, then runs structuralSharing on the returned OrderSummary so that if openCount and revenue are unchanged, the previous object reference is handed back and the component skips its render entirely. Because the hoisted toSummary has a stable identity, React Query never mistakes a new closure for a changed transform.
Configuration Trade-offs:
- Keep the
selectfunction referentially stable. An inlineselect: (o) => toSummary(o)allocates a new function every render, defeating React Query’s memoization and forcing the transform to re-run each time — hoist it or useuseCallback. selectcannot see component props or local state. If your projection depends on a search term or a prop, either close over it withuseCallback(accepting that a changing prop re-runs the transform) or move the prop-dependent slice into auseMemoafter the query.- Narrowing the return shape shrinks the notified surface, but it does not replace
notifyOnChangeProps. If you also want to ignore changes toisFetchingordataUpdatedAt, setnotifyOnChangeProps: ['data']so background refetch status changes never re-render a read-only view. structuralSharing(on by default) only preserves identity for deep-equal output. IftoSummaryreturned a nested object that is rebuilt each call, structural sharing would still reuse the equal leaves but must allocate the changed wrapper — return the flattest shape you can.
Implementation 2 — Expensive Shared Derivations with a Memoized Selector
select is per-observer, which is exactly wrong when an expensive computation — a multi-key sort, a group-by, a join across two cached lists — is needed by many components at once. Running it inside each observer’s select re-does the heavy work once per subscriber. The fix is a reselect-style memoized selector: a single function that caches its result against its inputs, so the computation runs once and every consumer reads the same stable reference. This is the read-time analogue of the lazy-join techniques in Reference vs Value Storage Models.
Steps:
- Read the raw cached data with a plain
useQuery(noselect), keeping the canonical array intact. - Build a memoized selector with
createSelectorfrom reselect (or a hand-rolled single-slot memo) that takes the raw array and produces the expensive derived shape. - Feed the query result into the selector inside a
useMemoso the selector’s input identity is the query’s stable data reference. - Consume the derived reference; because the selector memoizes, unrelated re-renders return the cached result untouched.
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { createSelector } from 'reselect';
interface Order {
id: string;
status: 'pending' | 'shipped' | 'delivered';
total: number;
region: string;
}
type OrdersByRegion = Record<string, Order[]>;
// Memoized: recomputes only when the input array reference changes.
const selectOrdersByRegion = createSelector(
(orders: Order[]) => orders,
(orders): OrdersByRegion =>
orders.reduce<OrdersByRegion>((acc, order) => {
(acc[order.region] ??= []).push(order);
return acc;
}, {}),
);
async function fetchOrders(): Promise<Order[]> {
const res = await fetch('/api/orders');
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.json() as Promise<Order[]>;
}
export function useOrdersByRegion() {
const { data } = useQuery({
queryKey: ['orders'],
queryFn: fetchOrders,
staleTime: 30_000,
structuralSharing: true,
});
// The selector's input is the query's stable data reference, so the
// group-by only recomputes when React Query hands back a new array.
return useMemo(() => (data ? selectOrdersByRegion(data) : {}), [data]);
}
Cache Behavior Impact. Leaving select off means useQuery returns the canonical Order[] reference, and structuralSharing: true guarantees that reference is stable across refetches that produce deep-equal data. createSelector memoizes on that reference: as long as React Query hands back the same array, selectOrdersByRegion returns its cached OrdersByRegion object without re-running the reduce. Every component that calls useOrdersByRegion reads the same memoized reference, so the group-by executes once per data change rather than once per subscriber — and the stable output lets React skip re-rendering consumers whose slice is unchanged.
Configuration Trade-offs:
- Relying on reference equality means
structuralSharingmust stay on. If you disable it (structuralSharing: false), every refetch produces a new array reference and the memoized selector recomputes on each background poll — the opposite of the intent. - A single-slot memo (reselect’s default) only caches the most recent input. If two components pass different arrays into the same selector on alternating renders, the cache thrashes; use
createSelectorwith a per-instance factory orweakMapMemoizefor that case. - This pattern trades
select’s notification-narrowing for shared computation. The consuming component still subscribes to the wholedata, so pair it withnotifyOnChangeProps: ['data']if you want to ignore fetch-status churn. - If the derived shape must also filter by a component-specific prop, split the pipeline: memoize the expensive shared group-by here, then apply the cheap per-component filter in a downstream
useMemo.
Implementation 3 — Entity-Level Derived Fields with Apollo Read Functions
When the derived value is a property of the entity itself — a fullName composed from firstName and lastName, a displayPrice that applies a currency format — it should not be recomputed on every screen that shows the entity. Apollo’s InMemoryCache lets you declare a field read function in typePolicies so the derived field is resolved centrally, at read time, for every query that requests it. The value never touches the network and is computed once per cache read against the normalized entity.
Steps:
- Add a
typePoliciesentry for the entity type in theInMemoryCacheconstructor. - Declare the derived field with a
readfunction that composes it from sibling fields viareadField. - Request the derived field in your GraphQL query exactly as if it were a server field.
- Let Apollo resolve it locally; no resolver or network call is involved, and the value stays consistent across every query that reads it.
import { InMemoryCache, gql, useQuery } from '@apollo/client';
export const cache = new InMemoryCache({
typePolicies: {
User: {
fields: {
// Derived field computed from sibling fields at read time.
fullName: {
read(_existing, { readField }) {
const first = readField<string>('firstName') ?? '';
const last = readField<string>('lastName') ?? '';
return `${first} ${last}`.trim();
},
},
},
},
},
});
const USER_QUERY = gql`
query User($id: ID!) {
user(id: $id) {
id
firstName
lastName
fullName @client
}
}
`;
export function useUserName(id: string) {
const { data } = useQuery(USER_QUERY, { variables: { id } });
return data?.user.fullName as string | undefined;
}
Cache Behavior Impact. The read function runs whenever a query resolves the fullName field against the normalized User entity, so the derivation is defined once and applied everywhere the field is requested — there is no per-component projection to keep in sync. Because firstName and lastName live in one normalized store slot, a mutation to either field invalidates every query reading fullName automatically. The @client directive tells Apollo to resolve the field locally rather than send it to the server, keeping the derived value out of the network payload while still participating in cache normalization and update propagation.
Configuration Trade-offs:
- A
readfunction must be pure and cheap; it runs on every cache read of that field. Expensive derivations belong in a memoized selector at the component layer, not intypePolicies. - Apollo read policies centralize entity-level values but cannot see React component state — they are the wrong tool for screen-specific shapes, where React Query
selector auseMemois the better fit. - Returning a fresh object from a
readfunction on every read breaks referential stability the same way an inlineselectdoes; return primitives or memoize non-primitive derived fields. - This pattern is Apollo-specific. In React Query there is no cache-level field policy, so the equivalent centralization is a shared memoized selector plus a stable
select, as in Implementations 1 and 2.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Component re-renders on every parent render despite showing the same derived value | select (or the projection) is an inline arrow function, so React Query sees a new transform identity each render and re-runs it |
Hoist the transform to module scope or wrap it in useCallback; verify with a console.count inside the function that it stops firing on unrelated renders |
| Expensive group-by runs once per subscriber, spiking main-thread time | The derivation lives inside each observer’s select, which is per-observer by design |
Move the heavy computation to a shared reselect createSelector read after a plain useQuery; leave select for cheap per-component slices |
| A read-only summary re-renders on every background refetch | The observer is notified of isFetching and dataUpdatedAt changes, not just data |
Set notifyOnChangeProps: ['data'] on the useQuery so status-only changes do not wake the component |
useMemo over query data still recomputes every render |
select upstream rebuilds a new object each call, so data’s reference changes even when values are equal |
Return the flattest possible shape from select and confirm structuralSharing is enabled so equal output reuses the prior reference |
Apollo fullName field returns undefined |
The field is requested without a local read policy, or the @client directive is missing from the query |
Add the read function under the entity’s typePolicies and mark the field @client in the GraphQL document |
Frequently Asked Questions
Does the React Query v5 select function run for every observer or once per query?
select runs per observer, not per query. Each useQuery call that subscribes to the same queryKey gets its own select invocation and its own memoized result. This is what lets two components derive different view models from one cached array without a second network request — but it also means an inline select recomputes on every render of that component unless the function identity is stable. Hoist the transform or wrap it in useCallback so React Query can memoize across renders.
Why does my component still re-render when select returns the same data?
React Query memoizes the select result by comparing the previous output to the new output with structuralSharing. If select returns a freshly constructed object or array on each call, structural sharing can preserve references only for deeply equal leaves, not for a wrapper object you rebuild every time. Return primitives where you can, keep the derived shape flat, and confirm structuralSharing has not been disabled on the query.
When should I use select versus useMemo versus a reselect selector?
Use select for cheap projections that should also shrink the notified surface, so the observer wakes only on the derived slice. Use useMemo when the derivation depends on component props or local state that select cannot see. Use a reselect-style memoized selector when the computation is expensive and shared across many components, so the heavy work runs once and every consumer reads the same cached, referentially stable result.
How do Apollo field read functions differ from React Query select for derived fields?
An Apollo read function lives in the InMemoryCache typePolicies and computes a derived field at read time for every query that requests it, centrally. React Query select is per-observer and per-component. Apollo read policies are the right tool when the derived value is a property of the entity itself — a fullName from firstName and lastName — while select is better when the shape is specific to one screen and depends on how that screen wants to render the data.
Related
- State Architecture & Cache Fundamentals — the parent section covering cache layering, state boundaries, and the storage models that determine what a selector can keep referentially stable.
- Reference vs Value Storage Models — how the cache decides between storing a normalized reference and an inlined value, which governs whether structural sharing can preserve identity through a derivation.
- Structural Sharing and Referential Stability — the deep-equality reference-reuse mechanism that every pattern on this page depends on to skip re-renders.
- Deriving Filtered Views From a Normalized Cache — a focused recipe for filtering, sorting, and grouping a cached list per component with
selectwhile keeping unfiltered subscribers from re-rendering. - Normalization Principles for UI — how to shape the normalized store so derivations read a flat, join-friendly source rather than a deeply nested tree.