Combining Multiple `entities` States in the `features` Layer: Derived State Design and Zustand Selector Re-render Optimization
If you've worked with React, Zustand, and TanStack Query on real projects, you'll find immediately applicable insights in this article.
As a frontend codebase grows, you'll eventually hit a wall: the cart screen needs to display a product list, user info, and coupon status all at once — but these three things are managed in different layers, in different ways. The totalPrice calculation ends up scattered across three components, and whenever one side updates, the other falls behind. Early on, I used to subscribe to the entire useUserStore() inside a component, stuff data fetched with useQuery back into useState, and call it a day — until I experienced state colliding from two sources and a render explosion.
The strategy in this article boils down to just two principles. Clearly separating server state from client state, and narrowing subscription scope with selectors — get these two right, and you can eliminate unnecessary re-renders and sync bugs at the same time. We'll walk through real code to explain why the features layer in Feature-Sliced Design (FSD) architecture is the right place to combine state from multiple entities, and how to combine TanStack Query's select with Zustand's selector + useShallow to design derived state cleanly.
Core Concepts
Derived State and SSOT
Simply put, Derived State is a value that is calculated or combined from one or more source-of-truth states. It updates automatically when the source changes, is never stored independently, and is always obtained through computation.
The total price of items in a cart isn't something you store — it's a value you compute as "product list × quantity." Storing it separately actually introduces sync bugs. The key is to trust only the source state and derive everything else.
This principle is called SSOT (Single Source of Truth). It's the design principle of managing the same data in exactly one place and deriving everything else from it — the moment you manage the same value in multiple places, a bug where the two values diverge is inevitable. You've probably experienced this at least once.
Where Should Derived State Live in FSD?
FSD has a unidirectional dependency structure of app → pages → widgets → features → entities → shared. Each layer can only reference layers below it.
| Layer | Role | Derived State Location? |
|---|---|---|
entities |
Domain entity units like User, Product, Order | Only derivation within a single entity |
features |
Actual user interactions using multiple entities | The right place for multi-entity combined derived state |
widgets |
Independent UI blocks | Consumes derived state from features |
Slices in the entities layer must not know about each other. Having the cart entity directly reference the user entity is an FSD violation. That's why derived state that combines multiple entities — like "the total price of cart items selected by the user" — naturally belongs inside a custom hook in the features layer.
Server State vs. Client State: What Happens When You Mix Them
Honestly, there was a time when I didn't distinguish between the two and put everything in Zustand. The results were disastrous — when new data arrived from the server, it would collide with the Zustand store, and because cache invalidation timing was off, stale data would linger on screen.
In one line: "Never copy data coming from the server into Zustand." This single principle prevents countless sync bugs.
| State Type | Characteristics | Responsible Tool |
|---|---|---|
| Server state | Async remote data, requires caching and revalidation | TanStack Query |
| Client state | UI state, user selections, temporary data | Zustand |
How Zustand Selectors Reduce Re-renders
Zustand uses Object.is by default to compare previous and new values. It only re-renders a component when the value returned by the selector has changed.
// Anti-pattern: subscribing to the entire store → re-renders whenever any state changes
const state = useUserStore();
// Recommended: selectively subscribe to only needed values → re-renders only when that value changes
const username = useUserStore((state) => state.user.name);The problem arises when returning objects or arrays. Returning an object like { name, role } creates a new reference every time, even if the actual values are the same, so Zustand always considers it "changed." This is where useShallow comes in. I remember the first time I used this pattern — I forgot useShallow and the console was flooded with warnings. Once you experience it, you never forget it.
import { useShallow } from 'zustand/react/shallow';
// Without useShallow → new object reference every render → risk of infinite re-renders
const { name, role } = useUserStore(
(s) => ({ name: s.user.name, role: s.user.role })
); // ❌
// With useShallow → shallow comparison skips re-render when actual values haven't changed
const { name, role } = useUserStore(
useShallow((s) => ({ name: s.user.name, role: s.user.role }))
); // ✅Simply put: useShallow is a shallow comparison hook officially introduced in Zustand 4.x. It prevents unnecessary re-renders when using objects or arrays as selector return values, and has a smaller bundle size than the older
shallowcomparison function.
Practical Application
Checkout Summary Screen: Combining Server and Client State
Suppose you're building a checkout summary screen. The list of selected product IDs (client state) and the coupon code (client state) live in Zustand, while the actual product data (server state) lives in TanStack Query. We combine the two in a custom hook in the features/checkout layer.
// features/checkout/model/useCheckoutSummary.ts
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useShallow } from 'zustand/react/shallow';
import { useCartStore } from '@/entities/cart';
import { fetchProductsByIds } from '@/entities/product';
export function useCheckoutSummary() {
// Client state: safely subscribing to multiple values with useShallow
const { selectedIds, couponCode } = useCartStore(
useShallow((s) => ({
selectedIds: s.selectedIds,
couponCode: s.couponCode,
}))
);
// Since selectedIds is an array, the queryKey becomes an array inside an array.
// TanStack Query compares keys via JSON serialization, so putting arrays in keys is safe.
const { data: selectedProducts } = useQuery({
queryKey: ['products', selectedIds],
queryFn: () => fetchProductsByIds(selectedIds),
// The cache source is preserved; only the transformed value is memoized.
// If the select function reference changes, memoization breaks — so it's better to define it outside.
select: (products) => products.filter((p) => selectedIds.includes(p.id)),
enabled: selectedIds.length > 0,
});
// Receives the already-filtered result from select and computes the total
const totalPrice = useMemo(() => {
if (!selectedProducts) return 0;
return selectedProducts.reduce((sum, p) => sum + p.price * p.quantity, 0);
}, [selectedProducts]);
return { selectedProducts, totalPrice, couponCode };
}| Code Point | Description |
|---|---|
Subscribing to client state with useShallow |
Re-renders if either selectedIds or couponCode changes, but skips if neither changes |
select option |
Preserves the original TanStack Query cache data; only the transformed value is memoized. If the select function reference changes, memoization is neutralized — so defining it outside is safer than an inline function |
Final derivation with useMemo |
Recalculates total price only when selectedProducts changes |
enabled: selectedIds.length > 0 |
Prevents unnecessary network requests with an empty array |
Reference Stabilization: External Selector Definition Pattern
Defining selectors inline inside a component creates a new function on every render. This is especially problematic with TanStack Query's select option — an inline function creates a new function reference on every render, completely neutralizing memoization. It's much better to define selectors outside the component.
// entities/product/model/productSelectors.ts
// Defined at module level → always guarantees the same function reference
export const selectAvailableProducts = (state: ProductState) =>
state.products.filter((p) => p.stock > 0);
// When a parameter is needed: currying pattern
export const selectProductById = (id: string) => (state: ProductState) =>
state.products.find((p) => p.id === id);// Usage in a component
import { selectAvailableProducts, selectProductById } from '@/entities/product';
function ProductList() {
const availableProducts = useProductStore(selectAvailableProducts);
return <>{/* ... */}</>;
}
function ProductDetail({ id }: { id: string }) {
// useMemo is semantically more appropriate for memoizing a value (function).
// useCallback is conventionally used when memoizing the function declaration itself, like event handlers.
const selectById = useMemo(() => selectProductById(id), [id]);
const product = useProductStore(selectById);
return <>{/* ... */}</>;
}Advanced: Optimizing Multi-Store Derived State with proxy-memoize
This section covers advanced content. The two patterns above are sufficient to cover most cases.
When you need to reference multiple entity stores at once and perform heavy computation — like in a dashboard — proxy-memoize is useful. Because it tracks only the properties actually accessed as dependencies, you don't need to enumerate input selectors one by one like you do with reselect.
// features/dashboard/model/useDashboardStats.ts
import { memoize } from 'proxy-memoize';
import { useOrderStore } from '@/entities/order';
import { useProductStore } from '@/entities/product';
import { useMemo } from 'react';
// Minimal type structure for reference
// type OrderState = { orders: Array<{ id: string; amount: number }> }
// type ProductState = { products: Array<{ id: string; salesCount: number }> }
type CombinedState = {
orders: OrderState['orders'];
products: ProductState['products'];
};
// Defined at module level → proxy-memoize tracks access paths via Proxy
const selectDashboardStats = memoize((state: CombinedState) => ({
totalRevenue: state.orders.reduce((sum, o) => sum + o.amount, 0),
topProducts: state.products
.filter((p) => p.salesCount > 100)
.sort((a, b) => b.salesCount - a.salesCount)
.slice(0, 5),
}));
export function useDashboardStats() {
const orders = useOrderStore((s) => s.orders);
const products = useProductStore((s) => s.products);
// proxy-memoize returns the cached result when input references are the same.
// The outer useMemo is a defensive wrapper in case you don't fully trust the proxy-memoize cache.
return useMemo(
() => selectDashboardStats({ orders, products }),
[orders, products]
);
}In one line: proxy-memoize uses JavaScript Proxy to automatically register only the properties actually accessed inside the function as dependencies at runtime. While
reselectrequires you to explicitly declare what values you depend on,proxy-memoizetracks this automatically at runtime.
The Most Common Mistakes in Practice
-
Copying server data into Zustand: Storing data fetched with TanStack Query back into a Zustand store via
useEffect. When cache invalidation timing is off, stale data lingers on screen, and the amount of state to manage doubles. Any code usinguseEffecttosetStatea server response is your first candidate for cleanup. -
Defining selectors inline inside a component: Patterns like
useQuery({ select: (data) => data.filter(...) })create a new function on every render, preventing TanStack Query's memoization from working. Defining them outside the component or wrapping them withuseMemois much better. -
Calculating derived state while subscribing to the entire store: Subscribing with
const state = useCartStore()means the component re-renders whenever any value in the store changes. Narrowing the subscription to only the needed fields via a selector is far better for performance.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Single Source of Truth (SSOT) | Managing only the source state means derived values update automatically. Sync bugs from managing the same value in two places are eliminated at the root |
| Re-render optimization | Subscribing only to needed state via selectors means components don't react to unrelated state changes |
| Separation of concerns | Moving computation logic into selectors or hooks lets components focus solely on rendering |
| Testability | Selectors, being pure functions, are easy to unit-test without a store or component |
| FSD architecture fit | Derived state logic is co-located in custom hooks in the features layer, making code location predictable |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Upfront design cost | You must decide upfront what is server state and what is client state | Clearly dividing domain boundaries based on FSD layers helps |
| Infinite re-renders when useShallow is missing | Forgetting useShallow on object/array-returning selectors causes infinite re-renders |
Apply useShallow to all selectors that return objects or arrays |
| proxy-memoize change detection caveat with nested objects | Proxy may fail to detect changes in deeply nested structures | Can be mixed with reselect or useMemo for deeply nested cases |
| Multi-store combination complexity | Dependency relationships become complex when combining multiple domain stores | Encapsulating combination logic inside a features-layer custom hook is effective |
Closing Thoughts
Using the features layer as the combination point for derived state — handling server state with TanStack Query's select and client state with Zustand selector + useShallow each in their own way — lets you resolve unnecessary re-renders and sync bugs together. After applying this pattern, components become remarkably simple. You spend less time figuring out where to fetch state from, and can trace bug origins back to a single place.
Three steps you can start with right now:
-
Find slices in your current Zustand store that are holding server data and migrate them to TanStack Query. If you have code that
setStates a server response in auseEffect, that's your first candidate. -
Find code that subscribes to an entire store like
useUserStore(), and change it to select only the needed fields likeuseUserStore((s) => s.user.name). If you're returning objects or arrays, applyuseShallowalongside it. -
If you're combining multiple entity states directly inside a component, extract that logic into a
features/[domain]/model/use[FeatureName].tscustom hook. Inside that hook, combine TanStack Query and Zustand values and calculate derived state withuseMemo.
References
- Working with Zustand | tkdodo.eu
- React Query Selectors, Supercharged | tkdodo.eu
- Zustand Official Documentation — Reference
- shallow & useShallow | DeepWiki
- Selectors & Re-rendering | DeepWiki
- Federated State Done Right: Zustand, TanStack Query, and the Patterns That Actually Work | nextsteps.dev
- Feature-Sliced Design Official Documentation — Layers
- Does TanStack Query replace Redux/MobX? | TanStack Official
- proxy-memoize Official Documentation
- Zustand Architecture Patterns at Scale | Brainhub