176: TanStack Query v5 with TypeScript: Query Options, Mutations, Infinite Data, and Cache Contracts
Learning outcomes
By the end of this lesson, you should be able to:
- explain and apply typed API functions in a realistic implementation;
- explain and apply
queryOptionsin a realistic implementation; - explain and apply mutation variables and results in a realistic implementation;
- explain and apply cache updates in a realistic implementation;
- explain and apply infinite queries in a realistic implementation.
Prerequisites and retrieval
This lesson assumes that you have the earlier 01–06 foundation and the preceding lessons in this module. Before you read on, retrieve one concrete example from a previous project in which the same concern appeared. It might be a typed request wrapper, a mutation that had to update local data, or a paginated list whose cache became difficult to keep consistent. The point is not to memorize library terminology. It is to make a defensible decision inside a strict TypeScript codebase, using the compiler to model domain invariants while remaining clear that static types do not replace runtime validation.
Terminology
- Typed API functions: Transport adapters should return precise promises such as
Promise<Task>orPromise<Page<Task>>, but only after the response has been validated at runtime. The return type then becomes useful evidence for the rest of the query code rather than an unchecked promise about what the server might return. - queryOptions: The v5
queryOptionshelper lets you co-locate a query key and its function while preserving inference foruseQuery, prefetching, and cache access. This is particularly useful when the same query contract is consumed in more than one place. - Mutation variables and results: A mutation function's parameter and returned promise define the inferred variable and result types. The input sent to the server and the successful domain value received back should be treated as separate parts of the contract.
- Cache updates:
setQueryDatashould receive data compatible with the exact query contract. A cache write is not merely an assignment to a convenient object; it changes data that other consumers already expect to have a particular shape. - Infinite queries: Model page parameters and
getNextPageParamdeliberately. Infinite-query data contains pages and page parameters, so the pagination contract must remain explicit instead of being hidden behind a flattened array. - Key factories and scoping: Use typed helper functions to build stable hierarchical keys for tenant, entity, filters, and pagination. A key must include every value that changes data ownership or content, or otherwise unrelated requests can share a cache entry.
Mental model
Treat TanStack Query v5 with TypeScript: Query Options, Mutations, Infinite Data, and Cache Contracts as a design problem with observable inputs, outputs, invariants, and failure modes. The library call is only one part of the design. TanStack Query v5 gives you the strongest inference when API functions and query options are typed at the boundary, query keys are structured, and the relationship between mutations and cache updates is explicit. A strong implementation makes assumptions visible, narrows uncertainty at boundaries, and leaves enough evidence, such as tests, types, constraints, metrics, or diagrams, to show why the design is safe.
A useful sequence for both an interview answer and a production change is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
The sequence is a simple conceptual diagram of the decision flow. A requirement tells you what the feature must do; constraints rule out unsafe or impractical options; the model defines the data and invariants; implementation encodes that model; failure analysis asks what happens off the happy path; and verification provides evidence that the result works. Do not jump from a requirement directly to a library call. First state what must remain true, then choose the mechanism that enforces it.
Deep dive
1. Typed API functions
The recurring problem is that a query function often looks typed while still passing unchecked network data into the application. Let transport adapters return precise promises such as Promise<Task> or Promise<Page<Task>> after runtime validation. Query inference can then flow from queryFn without manually supplying every generic argument at each call site. The type describes the validated boundary, rather than pretending that a TypeScript annotation can inspect a server response at runtime.
Decision rule: Use typed API functions deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design. In particular, do not use a return annotation to conceal a parser that does not actually validate the response.
2. queryOptions
When the key and query function are declared separately, they can drift apart: one caller may use a slightly different key, or cache access may be given a data shape that does not match the query. The v5 queryOptions helper can co-locate a query key and function while preserving inference for useQuery, prefetching, and cache access. That gives the query contract one reusable home without requiring every consumer to repeat its generic types.
Decision rule: Use queryOptions deliberately when it makes the contract or invariant easier to prove. If it only reduces typing while hiding an assumption, prefer the more explicit design. A factory should still make changing inputs visible in the key and in the function that uses them.
3. Mutation variables and results
Mutations are where input and output contracts most often get blurred. A mutation function's parameter and returned promise define variable/result inference. Model server validation errors separately from successful domain responses: a request body is not the same thing as the saved entity, and an error response is not a successful result with a different shape. This separation gives callers a reliable type for the variables they pass and for the value they handle after success.
Decision rule: Use mutation variables and results deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design. Be especially cautious about claiming that a mutation succeeded before the server has returned a validated result.
4. Cache updates
setQueryData should receive data compatible with the exact query contract. If a mutation returns the authoritative version of an entity, that response is usually safer for updating the corresponding entity cache than reconstructing the entity from stale client state. Broader lists should be invalidated when membership or ordering may have changed, because a local edit cannot reliably account for every filter, sort order, or page in those lists.
Decision rule: Use cache updates deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design. A cache update that type-checks can still be semantically wrong if it writes under the wrong key or leaves related list queries stale.
5. Infinite queries
Infinite queries are not just ordinary queries whose result happens to be an array. Model page parameters and getNextPageParam deliberately. The cached pages and pageParams structure is part of the contract, and the next-page function must agree with the server's pagination scheme. Flatten that structure only in derived presentation data, where the UI needs a list; do not discard the page boundaries inside the cache itself.
Decision rule: Use infinite queries deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design. Define what signals that another page exists, and decide how an empty final page, a repeated cursor, or a failed page request is represented.
6. Key factories and scoping
Cache keys are part of correctness, not just an implementation detail. Use typed helper functions to build stable hierarchical keys for tenant, entity, filters, and pagination. Every parameter that changes data ownership or content should participate in the key. For example, tenant scope and filter values must not be omitted merely because the query function can access them another way; omitting them can make one request reuse another request's data.
Decision rule: Use key factories and scoping deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design. The key factory should make it difficult to invalidate or update one scope while accidentally targeting another.
Worked example
Consider a strict TypeScript codebase where the compiler is used to model domain invariants, without pretending that static types replace runtime validation. Start by writing the requirement in one sentence. Then list the input and output contracts, and identify which concept above owns each failure mode. This separates design responsibilities: parsing or validation belongs at the boundary; domain rules belong in the domain or service layer; persistence rules belong in the database or repository; and presentation rules belong in the client. Mixing those concerns can make a happy-path demo look shorter, but it makes edge cases and cache behavior much harder to reason about.
The following small example is intentionally written with TanStack Query v5 object syntax. The query key identifies the task being read, while the query function obtains it. The mutation function owns the update operation, and successful mutation handling invalidates the broader task queries so list membership or ordering is not assumed to be unchanged.
const taskQuery = useQuery({
queryKey: ['tasks', taskId],
queryFn: () => api.tasks.get(taskId),
});
const saveTask = useMutation({
mutationFn: api.tasks.update,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
Read this as a contract rather than as a complete application. taskId must identify the same task that the query function requests. api.tasks.get and api.tasks.update need trustworthy types at their boundary, and the invalidation key must match the key hierarchy used by the relevant task queries. If the update response is authoritative, an implementation may also update the exact entity cache, but it should not use that optimization to avoid invalidating lists whose membership or order may have changed.
Walk the example through at least four cases: the normal path; an empty or missing value; a duplicate, retry, or concurrent path where that concern applies; and a dependency failure. For each case, state which layer detects the problem and what the caller observes. For instance, missing input may be rejected before the request, malformed server data should be rejected at the transport boundary, and a failed dependency should remain visible as a mutation or query error rather than being represented as a successful domain value. This is the level of explanation expected in a senior code review or technical interview.
Production perspective
Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high cardinality. Query inference will not resolve a server schema mismatch, and a successful local cache write does not prove that other scoped or filtered queries are current. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence.
When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume that the client can be modified and that network input is untrusted. TanStack Query manages server-state lifecycles; it does not provide authorization, validate an untrusted response by itself, or make an unsafe cache key safe.
Guided lab
Create typed query-option factories for task detail and filtered task lists. Then create create/update mutations and one infinite query. Use only TanStack Query v5 object syntax, and verify that invalid cache writes fail type checking. The goal is not simply to make requests compile: prove that keys, variables, results, page parameters, and cached values agree.
Complete the lab with this discipline:
- Write the requirement and two non-requirements. The non-requirements keep the implementation from expanding into unrelated behavior.
- List input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note.
The type-checking step is part of the exercise. Deliberately try to write a value that does not satisfy an exact query contract, observe the compiler failure, and then decide whether the contract or the attempted write is wrong. Also inspect the runtime behavior for loading, empty, error, stale, and successful states; a compile-time success cannot tell you whether the UI handles those states correctly.
Edge cases and failure modes
- Typed API functions: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Confirm that runtime validation rejects malformed responses instead of merely asserting a type.
- queryOptions: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Confirm that all inputs that affect the result appear in the generated key.
- Mutation variables and results: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Distinguish rejected variables and server errors from a successful result.
- Cache updates: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check both the value's shape and the key's scope, then verify whether related lists need invalidation.
- Infinite queries: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Include an empty final page, an invalid or repeated page parameter, and a page-level failure where those cases are possible.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. - Testing only the happy path and therefore discovering contracts only after integration.
- Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
- Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.
For debugging, reproduce the smallest failing case and inspect the actual value or execution plan. Trace the boundary where the invariant first becomes false, then fix the owning layer rather than adding a downstream patch. In practice, inspect the source or build first for incorrect inferred types, the Network or HTTP boundary for request and response data, the server or route for status and validation errors, the database or query for persistence behavior, and deployment or configuration when environments disagree. A cache symptom may be caused by a wrong key, an unvalidated response, an incomplete invalidation, or a legitimate stale state; inspect those possibilities rather than treating every stale value as a rendering bug.
Interview questions
- What problem do typed API functions solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does
queryOptionssolve, and what trade-off or failure mode would make you choose a different approach? - What problem do mutation variables and results solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do cache updates solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do infinite queries solve, and what trade-off or failure mode would make you choose a different approach?
Checkpoint
Without notes, explain TanStack Query v5 with TypeScript: Query Options, Mutations, Infinite Data, and Cache Contracts to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code. A strong explanation should connect the type or key contract to an observable runtime behavior, not just recite the feature names.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or stricter reliability.
