This episode covers the hybrid pattern: TanStack Query for server state and Zustand or Redux for UI state, as well as treating the cache as a single source of truth with getQueryData so data isn't duplicated in two places.

The most common architectural mistake in React applications: storing server data in a global store. Developers put fetch results into Redux or Zustand, then two systems compete to manage the same data — TanStack Query already stores the cache, the store also keeps a copy, and the two can fight.
Episode 16 introduces the hybrid pattern: TanStack Query manages server state, Zustand or Redux manages UI state. Then we discuss how to treat the cache as a single source of truth.
The division of tasks is clear-cut. TanStack Query handles all data that comes from the server — fetch, cache, retry, invalidation. The store handles state that is born and dies on the client:
TanStack Query → server state: todos, users, profile, notifications
Zustand/Redux → UI state: open modal, theme, cart, active filtersThe division TanStack Query → server state and Zustand/Redux → UI state gives each system a clear domain to manage. There are no two sources of truth for the same data.
Zustand is a good fit for lightweight UI state. Here's an example of a cart that stores product ids (UI state) while product data is fetched from TanStack Query:
import { create } from "zustand"
export const useCartStore = create((set) => ({
productIds: [],
cartOpen: false,
addProduct: (id) =>
set((state) => ({ productIds: [...state.productIds, id] })),
toggleCart: () => set((state) => ({ cartOpen: !state.cartOpen })),
}))useCartStore only stores productIds and cartOpen — not the product data itself. addProduct and toggleCart mutate purely UI state. The product detail data is still fetched via useQuery in components.
Components use both sources without conflict: the store for UI control, the query for data:
function CartButton() {
const { productIds, cartOpen, toggleCart } = useCartStore()
const { data: products } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
})
const selected = products?.filter((p) => productIds.includes(p.id))
return (
<button onClick={toggleCart}>
Keranjang ({selected?.length ?? 0}) {cartOpen ? "tutup" : "buka"}
</button>
)
}productIds from the store and products from the query are combined only at render time. selected is computed from the ids stored in the store and the full data from the cache — no duplication, because the store never stores product objects.
Sometimes a component needs data that is already in the cache without creating a new query — for example after a mutation, or for non-render logic. getQueryData is the gateway:
import { useQueryClient } from "@tanstack/react-query"
function TodoSummary() {
const queryClient = useQueryClient()
const todos = queryClient.getQueryData(["todos"]) ?? []
const done = todos.filter((t) => t.completed).length
return <p>{done} dari {todos.length} todo selesai</p>
}queryClient.getQueryData(["todos"]) reads the cache without fetching and without automatic re-rendering. getQueryData returns the data as is — if you want to subscribe to changes, use the regular useQuery.
The golden rule: never copy server data into the store. If the same product is stored in both the cache and the store, they can get out of sync, and invalidation in TanStack Query won't touch the copy in the store. Server data lives in exactly one place: the TanStack Query cache.
After a mutation, update the cache with setQueryData (episodes 6 and 11) so all components reading that query immediately see the new data — without a store intermediary:
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: (newTodo) => {
queryClient.setQueryData(["todos"], (old) => [...(old ?? []), newTodo])
},
})setQueryData(["todos"], ...) writes directly to the only source of truth. All components using ["todos"] will update along with it — this is the power of the cache as a single source of truth.
Warning
Be careful about putting whole objects from a query into the store for "ease of access". That creates a second copy that easily gets out of sync. Store ids, not objects, and let the query fetch the data.
Even though the hybrid pattern suggests TanStack Query for server state, there are cases where a store is still needed: truly global and rapidly changing state like theme, sidebar state, multi-step forms, and a cart that needs to be shared across pages. The point isn't to remove the store, but to limit the store to the UI domain only.
Episode 16 summarized the modern architecture pattern: TanStack Query for server state, Zustand or Redux for UI state, and a cache treated as a single source of truth via getQueryData and setQueryData.
Key takeaways:
getQueryData reads the cache without fetching or re-rendering.setQueryData after a mutation.In the next episode, episode 17, we will discuss devtools and debugging — monitoring the cache and query statuses with React Query Devtools, and troubleshooting common problems like queries that don't refetch, infinite loops, error serialization, and cache memory leaks.