This episode distinguishes server state from client state, then establishes rules for when API data goes into Zustand and when into TanStack Query or SWR. You also learn the hybrid pattern that separates the server cache from the global store, with an auth and user profile example.

A modern application doesn't just keep UI state in memory — it also talks to APIs to fetch user data, product lists, and more. A frequently asked question: does server data go into Zustand or into a cache library like TanStack Query? Episode 12 answers that question by distinguishing two different categories of state and laying out the hybrid pattern used by production teams.
You'll understand the difference between server state and client state, the criteria for when API data goes into Zustand, when to use TanStack Query or SWR, and then build an auth and user profile example with the hybrid pattern.
Server state is data owned by the server and synchronized through APIs: user profiles, lists of posts, account balances. Client state is data born and lived in the browser: modal status, form values, the active theme, or the current page.
This distinction matters because it determines where the data lives. Server state needs caching, retry, and invalidation when the data changes on the server. Client state needs speed and doesn't need to sync with the server. Mixing both in a single store makes the code hard to predict: the store ends up handling loading, error, caching, and refetching all at once.
There are several situations where keeping API data in Zustand makes sense:
The most common example is the session token and user profile. The token must be available synchronously for every request and shouldn't change unpredictably:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AuthState {
token: string | null
user: User | null
setSession: (token: string, user: User) => void
clearSession: () => void
}
export const useAuth = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setSession: (token, user) => set({ token, user }),
clearSession: () => set({ token: null, user: null }),
}),
{ name: 'auth-session' },
),
)setSession(token, user) stores session data once per login, and any component can read the token synchronously without waiting for a new API response.
When data changes often on the server and is read many times over, use a server cache library. TanStack Query and SWR provide caching, request deduplication, retry, refetch on window focus, and query invalidation — features Zustand doesn't have.
Install TanStack Query in your project:
npm i @tanstack/react-queryThen define a query with a queryKey and queryFn:
import { useQuery } from '@tanstack/react-query'
export function useUserProfile(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () =>
fetch(`/api/users/${userId}`).then((res) => res.json()),
staleTime: 60_000,
})
}queryKey: ['user', userId] creates a cache per user, and a staleTime of 60 seconds prevents excessive refetches.
The healthiest combination: Zustand stores global client state like tokens and preferences, TanStack Query stores the server cache. When the user changes, invalidate queries in TanStack Query and update the token in Zustand:
async function login(email: string, password: string) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
useAuth.getState().setSession(data.token, data.user)
await queryClient.invalidateQueries({ queryKey: ['user'] })
}useAuth.getState().setSession(...) updates the global store, while invalidateQueries forces TanStack Query to refetch the related profile. With this pattern, the store never holds a server cache, and the server cache never handles global actions.
Episode 12 separates two worlds of state: server state that lives in TanStack Query or SWR, and client state that lives in Zustand. Session and UI data go into Zustand; data that changes on the server and is read many times goes into a query cache with invalidation.
Key takeaways:
In the next episode we will discuss performance and transient updates — controlling re-renders with selectors and useShallow, avoiding new objects on every render, and updating state like a progress bar without triggering renders.