How to Separate Server State (TanStack Query) and Client State (Zustand) in the FSD `entities` Layer
At some point in frontend development, you'll get stuck on this question: "Should this data go into Zustand, or should I leave it to TanStack Query?" I used to decide with the vague heuristic of "if it seems like it'll be used globally, put it in Zustand" — and inevitably, the Zustand store ended up stuffed with server data, caches started being managed in two separate places, and sync bugs started popping up.
Looking for the answer, I discovered that the entities layer in Feature-Sliced Design (FSD) makes a great starting point. If you're new to FSD, it's worth briefly skimming the official docs — put simply, it's an architectural methodology that "separates code by domain concepts like User, Product, and Order into slice units, arranged according to layer rules." Within this structure, clearly defining the roles of the api and model segments naturally draws the boundary between server state and client state. This article walks through code to show how to divide the responsibilities of TanStack Query and Zustand based on the api/model segments of the entities layer — and why the boundary belongs exactly where it does.
Core Concepts
Server State and Client State Are Fundamentally Different Animals
Honestly, treating state as a single kind of thing is itself the source of much confusion. Data fetched from a server and data created in the UI have completely different lifecycles and management approaches.
| Category | Definition | Core Concern | Tool |
|---|---|---|---|
| Server State | Remote data fetched from an API | Caching, invalidation, background refetching | TanStack Query |
| Client State | State that exists only within the app | UI decisions, filters, selected values | Zustand |
Server state is data you "don't own." When the server changes, your cache becomes stale, and other tabs or users may modify the same data. TanStack Query handles this complexity with staleTime, invalidateQueries, and background refetching.
Client state, on the other hand, is fully owned by you. "Which item has the user selected?", "What has the user typed into the search filter?" — these don't need to be synced with the server.
Core Principle: Not duplicating server data into Zustand is the absolute rule of this architecture. If TanStack Query is already managing the cache and you put the same data into Zustand, the responsibility of keeping the two caches in sync falls entirely on you.
Segment Role Assignments in the FSD entities Layer
The entities layer represents real-world domain concepts. Each slice contains three segments, and state management tools map naturally onto this structure.
entities/
user/
api/
userQueries.ts ← TanStack Query (queryOptions, queryFn)
userApi.ts ← fetch functions
model/
userStore.ts ← Zustand (selected values, filters)
types.ts
ui/
UserCard.tsx
index.ts ← public API (externally exposed interface)apisegment: Everything that communicates with the server.queryOptionsand the actual fetch functions live here.modelsegment: Client state. Zustand stores and type definitions go here.uisegment: The visual representation of the domain. Combines the two segments above.
With this separation clear, any team member can quickly determine just from the file structure whether "this bug is a server data problem or a UI state problem."
The queryOptions Helper — Why It Pairs Perfectly with FSD
The queryOptions() helper introduced in TanStack Query v5 bundles queryKey and queryFn into a single object. Since it can be defined without React, it's a perfect fit for placement in entities/*/api.
queryKey is the unique key TanStack Query uses to identify the cache. It's written as an array like ['users', id] — the same key shares the same cache, while different keys create separate caches. If you've ever experienced cache desync from a typo after copying keys around, you'll quickly appreciate how convenient it is to manage them in one place with the factory pattern below.
// entities/user/api/userQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchUser, fetchUserList } from './userApi';
export const userQueries = {
list: () =>
queryOptions({
queryKey: ['users'],
queryFn: fetchUserList,
staleTime: 1000 * 60 * 5, // list doesn't change often, set to 5 minutes
}),
detail: (id: string) =>
queryOptions({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
// detail is directly manipulated via optimistic updates, so keep default staleTime(0)
}),
};The staleTime on list is set to 5 minutes while detail has none — this is an intentional difference. The detail query's cache is directly manipulated during optimistic updates covered later, so the default is kept to allow immediate refetching after the server responds.
TkDodo Pattern: A pattern proposed by TkDodo, a contributor to the TanStack Query official docs, that separates
queryOptionsanduseQuerycalls to clearly define layer boundaries. Options are defined in theapisegment, anduseQueryis called from theuisegment or a higher layer.
Practical Application
Example 1: Full entities/user Slice Setup
The most common real-world scenario: fetching a user list from the server while managing the selected user and search filter on the client.
Server State — api segment
// entities/user/api/userApi.ts
export async function fetchUserList(): Promise<User[]> {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
}
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}Client State — model segment
// entities/user/model/userStore.ts
import { create } from 'zustand';
interface UserStore {
selectedUserId: string | null;
searchFilter: string;
setSelectedUser: (id: string | null) => void;
setSearchFilter: (filter: string) => void;
}
export const useUserStore = create<UserStore>((set) => ({
selectedUserId: null,
searchFilter: '',
setSelectedUser: (id) => set({ selectedUserId: id }),
setSearchFilter: (filter) => set({ searchFilter: filter }),
}));Public API — index.ts
// entities/user/index.ts
export { userQueries } from './api/userQueries';
export { useUserStore } from './model/userStore';
export type { User } from './model/types';
export { UserCard } from './ui/UserCard';External access must go through index.ts only. Internal structure changes won't affect external code.
| File | Role | Dependent Tool |
|---|---|---|
userApi.ts |
Actual fetch functions | None (pure functions) |
userQueries.ts |
queryKey + queryFn bundle | TanStack Query |
userStore.ts |
UI selection state storage | Zustand |
Example 2: Handling Optimistic Updates — Where the Boundary Gets Blurry
Optimistic Update is the pattern of "updating the UI preemptively before the server responds," and at first glance it's confusing whether this is server state or client state. The short answer: it's recommended to handle this inside TanStack Query's onMutate.
// features/update-user/model/useUpdateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userQueries } from '@/entities/user';
import { updateUser } from '@/entities/user/api/userApi';
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onMutate: async (updatedUser) => {
// In v5, cancelQueries is passed as QueryFilters
await queryClient.cancelQueries({
queryKey: userQueries.detail(updatedUser.id).queryKey,
});
const previousUser = queryClient.getQueryData(
userQueries.detail(updatedUser.id).queryKey
);
queryClient.setQueryData(
userQueries.detail(updatedUser.id).queryKey,
updatedUser
);
return { previousUser };
},
onError: (_err, updatedUser, context) => {
queryClient.setQueryData(
userQueries.detail(updatedUser.id).queryKey,
context?.previousUser
);
},
onSettled: (_data, _err, updatedUser) => {
queryClient.invalidateQueries({
queryKey: userQueries.detail(updatedUser.id).queryKey,
});
},
});
}Optimistic update state is ultimately "temporary server state while waiting for the server response." Handling it by directly manipulating the TanStack Query cache means there's no need to bring Zustand into the picture.
Example 3: Integrating Both States in the features Layer
The actual meeting point of these two states is the features layer (or above). Since FSD rules prohibit entities slices from importing each other, any logic that composes multiple entities must be moved to a higher layer.
// features/user-list/ui/UserList.tsx
import { useQuery } from '@tanstack/react-query';
import { userQueries, useUserStore } from '@/entities/user';
export function UserList() {
const { searchFilter, selectedUserId, setSelectedUser } = useUserStore();
const { data: users, isLoading } = useQuery(userQueries.list());
// When enabled: !!selectedUserId is false, queryFn is not called, so the non-null assertion is safe
const { data: selectedUser } = useQuery({
...userQueries.detail(selectedUserId!),
enabled: !!selectedUserId,
});
// Filtering is computed as a derived value on the client (useMemo recommended in real code)
const filtered = users?.filter((u) =>
u.name.toLowerCase().includes(searchFilter.toLowerCase())
);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{filtered?.map((user) => (
<UserCard
key={user.id}
user={user}
isSelected={user.id === selectedUserId}
onClick={() => setSelectedUser(user.id)}
/>
))}
</div>
);
}There's one key pattern here. selectedUserId is held by Zustand, but fetching the actual user data for that ID is done by TanStack Query. Zustand only holds the intent — "which user do I want to see" — while the actual data is pulled from the TanStack Query cache. This is exactly how the roles of the two tools intersect.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Clear separation of concerns | When a bug occurs, you can determine from the file structure alone whether it's a server issue or a UI state issue |
| Cache consistency | The single source of truth for server data is the TanStack Query cache alone, blocking sync bugs at the source |
| Testability | queryOptions can be tested without React; Zustand stores can be unit tested without a UI |
| Scalability | Each slice owns its own state, keeping coupling between slices low and allowing independent evolution |
| Bundle size | A much lighter combination of two libraries compared to Redux, with a bundle optimization benefit |
Testability is something you really have to experience firsthand. queryOptions lets you verify queryKey and queryFn without React Context or rendering, and Zustand stores let you unit test state changes without act(). After adopting this structure, I noticed a marked reduction in the time I spent writing API-related tests.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Boundary judgment difficulty | Some state like optimistic updates sits ambiguously between server/client | Handle by directly manipulating the cache inside TanStack Query's onMutate |
| No cross-entity imports | FSD rules prohibit entities slices from importing each other |
Move logic that composes multiple entities to the features layer or above |
| v5 cache sharing changes | TanStack Query v5 removed the contextSharing prop |
In multi-app environments like Module Federation, explicitly share the QueryClient instance |
| Over-fragmentation risk | Splitting every UI state into Zustand can make things more complex, not less | Simple component-internal state is better handled with useState |
Terminology note — Module Federation: A feature introduced in Webpack 5 that allows multiple independent builds (micro-frontends) to share code at runtime. In this environment, each app can have its own
QueryClientinstance, requiring a separate cache-sharing strategy to be designed.
The Most Common Mistakes in Practice
1. Syncing server data into Zustand
The pattern of copying TanStack Query results into Zustand via useEffect. It's the most common and most fatal mistake.
// Don't do this
useEffect(() => {
if (data) {
setUsers(data); // The moment you create the responsibility of syncing two caches
}
}, [data]);This creates the responsibility of keeping two caches in sync, and from that moment, the fight against sync bugs begins. Instead of copying data into Zustand, the recommended approach is to use data directly for computing derived values.
2. Directly importing other entities from within entities
Importing entities/user from entities/order breaks FSD's layer rules. Logic that needs to combine two entities must be moved to the features layer.
3. Putting simple toggle state into Zustand
State like isOpen or isExpanded used only inside a specific UI component is much better suited to useState. First ask "does this need to be globally accessible?" — if not, there's no reason to reach for Zustand.
Closing Thoughts
Maintaining this boundary means you stop spending time wondering "is this a server problem or a UI state problem?" when a bug occurs. Open the api segment and you'll find only server communication logic; open the model segment and you'll find only UI decision logic. Code with clear responsibilities reduces not only debugging time but also team onboarding time.
Three steps you can start with right now:
-
Open your existing Zustand store and check whether there are fields containing server API response data. If you see fields like
users: User[]orproducts: Product[], it's recommended to createqueryOptionsin the form ofuserQueriesorproductQueriesin the corresponding slice and migrate them to TanStack Query. -
Split the FSD directory structure into
entities/[domain]/api/andentities/[domain]/model/. A good starting point is placingqueryOptionsand fetch functions inapi, and only pure client state like selected values and filters inmodel. -
Define
entities/[domain]/index.tsas the public API and establish a team convention where external code only imports from this file. Once you standardize on the formimport { userQueries, useUserStore } from '@/entities/user', you'll have a stable boundary where internal structural changes don't affect external code.
References
- Usage with TanStack Query | Feature-Sliced Design Official Guide
- Usage with React Query | Feature-Sliced Design
- Layers | Feature-Sliced Design Layer Reference
- Zustand: The Minimalist State Architecture | FSD Official Blog
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work
- Separating Concerns with Zustand and TanStack Query
- React Query Request Factory: Reusable Type-Safe Hooks with Axios & FSD
- Does TanStack Query Replace Client State Managers? | TanStack Official Docs
- Rule-Based Schema-Driven Development with Orval × FSD | KINTO Tech Blog
- The Best React JS Architecture for 2026: Domain-Driven + FSD
- How We Cut 70% Bundle Size: The TanStack Query + Zustand Architecture
- State of React 2025: State Management