FullStack Course LogoFullStack Course
Module: Full Stack
Full Stack·153·16 MIN READ

153: Client State, Server State, and TanStack Query v5 Architecture

TOPICS COVERED: Client State, Server State, and TanStack Query v5 Architecture

Learning outcomes

By the end of this lesson, you can:

  • explain and apply state classification in a realistic implementation;
  • explain and apply query keys in a realistic implementation;
  • explain and apply query functions and cancellation in a realistic implementation;
  • explain and apply staleness and garbage collection in a realistic implementation;
  • explain and apply mutations and invalidation in a realistic implementation.

These outcomes are deliberately practical. The goal is not to recite TanStack Query terminology. You should be able to look at a feature, identify who owns each piece of state, choose a design that matches that ownership, and explain what happens when requests fail, overlap, or become stale.

Prerequisites and retrieval

This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before reading, retrieve one concrete example from a previous project where the same concern appeared. Perhaps a component kept a server response in local state, a mutation left a list visibly stale, or a request continued after the user had changed filters. The point of the exercise is to connect the terms to a real design problem.

The target is a defensible decision inside a production full-stack web application with a React client, a Node.js API, persistent storage, authentication, observability, and deployment concerns. A library can manage mechanics such as caching and refetching, but it cannot decide whether a value belongs to the client or the server. That ownership decision still has to come from the requirements.

Terminology

  • State classification: Local UI state includes open panels, draft input, and temporary selections. It is usually owned by the interface and changes as the user interacts with it.
  • Query keys: TanStack Query v5 query keys should encode every variable that changes the fetched result. If changing a filter, account, page, or sort order changes the response, that input belongs in the key.
  • Query functions and cancellation: Use the v5 object form and accept the provided signal when the transport supports cancellation. A query function should resolve the requested data or throw a meaningful error.
  • Staleness and garbage collection: staleTime controls freshness, while gcTime controls how long unused cached data remains. They answer different questions and should not be treated as two names for the same timeout.
  • Mutations and invalidation: A mutation changes remote state. Treat it as a precise engineering concept, not merely as vocabulary for a button that sends a request.
  • Optimistic updates: Optimism is appropriate when failure is uncommon and rollback is understandable. It is a consistency strategy, so the rollback and reconciliation behavior matter as much as the fast UI response.

Mental model

Treat Client State, Server State, and TanStack Query v5 Architecture as a design problem with observable inputs, outputs, invariants, and failure modes. React UI state and remotely owned server state have different lifecycles. A panel being open is local to an interface; a task list may be shared with other users, changed by another request, and invalidated by a deployment or permission change.

TanStack Query v5 is useful because it provides a coherent place for server-state concerns such as caching, loading status, retries, staleness, and invalidation. Without that separation, applications commonly copy the same response into several custom stores and then add ad hoc flags to keep those copies synchronized. The code may look small at first, but every extra copy creates another path that can become stale.

A strong implementation makes assumptions visible, narrows uncertainty at boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to prove why the design is safe. A useful interview and production sequence is:

text
requirement -> constraints -> model -> implementation -> failure analysis -> verification

Do not jump from a requirement directly to a library call. First state what must remain true. For example, a task shown as complete should reflect the server's accepted state, a result for one filter should not be displayed as the result for another filter, and an abandoned request should not unexpectedly overwrite the current view. Then choose the mechanism that enforces those invariants.

Deep dive

1. State classification

The first useful question is not “where can I put this value?” It is “who owns this value, and which lifecycle should control it?” Local UI state includes open panels, draft input, and temporary selections. Server state is asynchronously fetched, shared, can become stale independently of the current component, and is owned by a remote system.

For example, the text currently typed into a new-task form is a draft and can remain local to the form. The saved task list is different: it came from the API, may be used by several components, and can change without this component editing it. Keeping those categories separate makes loading, error, refetch, and authorization behavior easier to reason about.

Decision rule: Use state classification 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. In particular, do not copy query data into local state simply because a component already knows how to use useState; that copy now needs its own synchronization rules.

2. Query keys

TanStack Query v5 query keys should encode every variable that changes the fetched result. Use stable hierarchical arrays so invalidation can target an entity, a list family, or a scoped subset. A key such as ['items', filters] says that the cached result belongs to the items list and varies with filters.

The key is part of the cache contract, not an arbitrary label. If the API request includes a user, project, page, sort order, or filter but the key omits one of those values, two different responses can be treated as the same cached data. Conversely, a needlessly unstable key can cause avoidable cache misses. Query-key factories are useful when they centralize this structure and make the hierarchy consistent across queries and invalidation calls.

Decision rule: Use query keys deliberately when they make the contract or invariant easier to prove. If a key structure only reduces typing while hiding an assumption, prefer the more explicit design. When reviewing a query, compare the variables in the query function with the variables represented by the key.

3. Query functions and cancellation

Use the v5 object form and accept the provided signal when the transport supports cancellation. The query function receives context, including the signal, and the request should pass that signal through to fetch or another cancellation-aware transport. This matters when filters change quickly or a component no longer needs an in-flight request.

A query function should resolve data or throw a meaningful error rather than silently returning an error object. Returning an error object as if it were successful data moves the failure into rendering code and can make the query appear successful to caching and retry logic. The error should preserve enough context for the UI, logs, and diagnosis without exposing credentials or other sensitive request details.

Cancellation is not the same as making a request impossible to fail. It is a way to stop work that is no longer relevant and to prevent obsolete work from competing with the current request. The transport must actually honor the signal; passing a signal to a helper that ignores it does not provide cancellation.

Decision rule: Use query functions and cancellation 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. Check both ends of the boundary: the query function must pass the signal, and the transport must support the cancellation semantics you expect.

4. Staleness and garbage collection

staleTime controls freshness, while gcTime controls how long unused cached data remains. A query can be stale while still present in the cache, and it can be fresh while a component is using it. These are separate states.

Choose both values from product semantics rather than globally setting large values to hide refetches. A rapidly changing operational view may need a short freshness window. A reference list that rarely changes may tolerate a longer one. gcTime is about retaining inactive cache entries for possible reuse; it does not say how long the server data is correct.

When debugging an apparently unexpected request, inspect whether the query is stale, whether it has active observers, which events trigger refetching, and whether its key changed. When debugging memory or cache growth, inspect inactive entries and their gcTime instead of changing staleTime blindly.

Decision rule: Use staleness and garbage collection 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 chosen values should be explainable in terms of user-visible freshness and bounded resource use.

5. Mutations and invalidation

A mutation changes remote state. After success, either update exact cache data when the server response is authoritative or invalidate the affected query family so it refetches. The choice depends on what the response guarantees and how many cached views depend on the changed record.

Invalidating ['items'] can refresh list queries below that hierarchy, while a more specific key can limit the work when only one scoped result is affected. The invalidation target must match the key design. A mutation that succeeds but leaves related list or detail queries untouched creates a client that reports old information even though the server is correct.

The server remains the authority for authorization, validation, and persistence. A successful-looking client update is not proof that the user was allowed to perform the operation or that the database accepted it. Handle the mutation's pending, error, and success states explicitly, and make retries safe for the operation when retries are possible.

Decision rule: Use mutations and invalidation 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. Name the affected query family and explain why the selected cache update or invalidation is sufficient.

6. Optimistic updates

Optimism is appropriate when failure is uncommon and rollback is understandable. The interface shows the expected result before the server confirms it, which can make a toggle feel immediate. That convenience comes with a temporary consistency gap and a responsibility to repair the cache if the request fails.

Snapshot or cancel affected queries, apply the speculative update, roll back on error, and reconcile after settlement. Cancelling relevant queries prevents an in-flight refetch from racing with the optimistic value. The snapshot gives the rollback a known starting point. Reconciliation after settlement ensures that server-side defaults, authorization rules, timestamps, concurrent edits, or other canonical changes are reflected in the final cache.

Do not use optimism merely because it makes a demo look faster. If failure is common, the update has complicated side effects, or there is no reliable inverse operation, a pending state followed by an authoritative refetch is usually easier to trust.

Decision rule: Use optimistic 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. The design is incomplete until it describes success, failure, rollback, and reconciliation.

Worked example

Consider a production full-stack web application with a React client, a Node.js API, persistent storage, authentication, observability, and deployment concerns. Start by writing the requirement in one sentence, list the input and output contracts, and identify which of the concepts above owns each failure mode.

The important move is separation: parsing or validation belongs at the boundary; domain rules belong in the domain/service layer; persistence rules belong in the database or repository; presentation rules belong in the client. Mixing these concerns makes a happy-path demo look shorter, but it makes edge cases much harder to reason about. A client-side disabled button is not an authorization rule, and a query key cannot repair an API that returns data for the wrong user.

Here is a small client-side slice for listing items and creating one:

tsx
const listQuery = useQuery({
  queryKey: ['items', filters],
  queryFn: ({ signal }) => api.items.list({ filters, signal }),
});

const createItem = useMutation({
  mutationFn: api.items.create,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['items'] }),
});

The list key includes the filters, so separate filter values can have separate cache entries. The query function receives and forwards the cancellation signal. The create mutation does not assume that adding an item changes only one rendered component; it invalidates the items family so the relevant list queries can obtain authoritative data again. In a real implementation, also define the request and response types, handle non-success HTTP responses in the API helper, and ensure the server enforces authentication and authorization.

Walk the example with at least four cases: the normal path, an empty or missing value, a duplicate/retry/concurrent path where relevant, and a dependency failure. For each case, state which layer detects the problem and what the caller observes. For example, malformed input may be rejected at the request boundary, a duplicate may be rejected by a domain or database constraint, a network failure may place the query or mutation into an error state, and an authorization failure must come from the server rather than from a client-only check.

This is the level of explanation expected in a senior code review or technical interview. Do not stop at “the hook fetches the data.” Explain what identifies the cache entry, what happens when the request is abandoned, which data is considered stale, and how related views become correct after a mutation.

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. 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 the client can be modified and the network input is untrusted. TanStack Query can coordinate client behavior, but it cannot replace server-side validation, authorization, or database guarantees.

Guided lab

Build a task list with TanStack Query v5 only for server state. Include query key factories, cancellation, create/update mutations, invalidation, one optimistic toggle, and tests for error plus rollback.

Keep genuinely local concerns, such as form drafts, open panels, and temporary selections, in local UI state rather than placing them in the server-state cache. Give the list and any detail views a deliberate key hierarchy. Make the API helper throw for failed responses and pass the query signal to the transport. For the optimistic toggle, make the cached snapshot, rollback path, and final reconciliation observable in tests.

Complete the lab with this discipline:

  1. Write the requirement and two non-requirements.
  2. List input, output, and error contracts before implementation.
  3. Implement the smallest correct vertical slice.
  4. Add at least one invalid-input test and one edge-case test.
  5. Instrument or inspect the behavior instead of guessing.
  6. Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
  7. Explain one alternative design and why you did not choose it.
  8. Record a short “what would break at 10× scale?” note.

The lab is complete only when the tests cover more than the successful request. Include the behavior a developer would need to diagnose: which query key was used, whether cancellation reached the transport, what data was restored after a failed optimistic update, and whether invalidation refreshed every affected view.

Edge cases and failure modes

For each concept, test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Those categories are prompts for concrete cases, not a requirement to invent an edge case that the domain does not support.

  • State classification: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check that local drafts do not overwrite authoritative server data and that server errors are not represented as ordinary local values.
  • Query keys: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check that every result-changing variable is represented and that equivalent inputs produce the intended stable key.
  • Query functions and cancellation: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check both thrown failures and whether an obsolete request is actually cancelled or otherwise prevented from affecting current state.
  • Staleness and garbage collection: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check the difference between a stale entry that remains cached and an inactive entry that is eventually collected.
  • Mutations and invalidation: test absence, malformed input, duplicates, ordering/concurrency where applicable, and behavior at the smallest and largest credible sizes. Check retries, duplicate submissions, concurrent edits, and whether all affected list and detail views become correct.

Common mistakes and debugging

  • Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong. Start with ownership, contracts, and invariants rather than with a familiar hook configuration.
  • Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary” any values. These choices can make the immediate error disappear while leaving the boundary unverified.
  • Testing only the happy path and therefore discovering contracts only after integration. Exercise invalid data, server errors, cancellation, duplicate operations, and rollback behavior before relying on the feature.
  • Optimizing before measuring, or selecting a scalable mechanism without a scale requirement. A larger cache or more elaborate update strategy is not automatically an improvement.
  • Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees. A user can alter client code and send requests directly, so the server must enforce its own rules.

For debugging, reproduce the smallest failing case, inspect the actual value or execution plan, trace the boundary where the invariant first becomes false, and fix the owning layer rather than adding a downstream patch. For a stale list, inspect the exact query key and invalidation target. For an apparent race, inspect request timing, filters, and cancellation. For an optimistic-update failure, inspect the snapshot, rollback, and final refetch. Useful boundaries include the source or build, browser or DOM, Network or HTTP, server or route, database or query, and deployment or configuration.

Interview questions

  1. What problem does State classification solve, and what trade-off or failure mode would make you choose a different approach?
  2. What problem does Query keys solve, and what trade-off or failure mode would make you choose a different approach?
  3. What problem does Query functions and cancellation solve, and what trade-off or failure mode would make you choose a different approach?
  4. What problem does Staleness and garbage collection solve, and what trade-off or failure mode would make you choose a different approach?
  5. What problem does Mutations and invalidation solve, and what trade-off or failure mode would make you choose a different approach?

Answer each question with a concrete requirement, not just a definition. A strong answer names the owner of the state, the relevant invariant, the operational trade-off, and the failure evidence you would inspect.

Checkpoint

Without notes, explain Client State, Server State, and TanStack Query v5 Architecture 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.

If your explanation cannot account for loading, empty, error, stale, and success states, or cannot say which layer owns authorization and validation, it is not complete yet. The point of the checkpoint is to demonstrate a design you can reason about, not to reproduce the wording of this lesson.

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.

References

Reader page: /fullstack/lesson/153/client-state-server-state-and-tanstack-query-v5-architecture