Designing Your State Layer โ Where to Put What and Why
Day 10 of 40 โ React System Design Series

Hi, Iโm Richa โ a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons Iโve learned working on large codebases. My goal is to share practical insights โ not shortcuts โ for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If youโre serious about improving as an engineer, youโre in the right place.
Hey everyone, Richa here! ๐

Day 10. End of Week 2. ๐
This week we covered Context API, Zustand, Redux Toolkit, and React Query. Four different tools. And if you're like me โ the first question after learning all of them is:
"Okay but when I'm actually building an app โ which one do I use for what?"
That's exactly what today is about. No new tools. Just the mental model for putting it all together โ the state architecture decision framework I wish I had at the start of my career.
Because the mistake isn't using the wrong tool. The mistake is not having a framework for deciding in the first place.
The four categories of state
Every piece of state in a React app falls into one of four categories. Once you know the category โ the right tool is usually obvious.
1. Local UI state State that belongs to one component and nothing else needs to know about it.
// Examples
const [isOpen, setIsOpen] = useState(false); // dropdown open/closed
const [inputValue, setInputValue] = useState(''); // controlled input
const [activeTab, setActiveTab] = useState('profile'); // tab selection
2. Shared client state State that multiple components need โ but it lives only in the browser, not the server.
// Examples
// - Shopping cart (user hasn't checked out yet โ server doesn't know)
// - UI preferences (sidebar collapsed, theme)
// - Multi-step form state (wizard progress)
// - Notification toasts
3. Server state Data that lives on a server. You're displaying a cached copy in the UI.
// Examples
// - User list from /api/users
// - Product catalogue from /api/products
// - Order history from /api/orders
// - Any data that came from a fetch/API call
4. Global app config Values that are truly global, set once, and change rarely (or never) during a session.
// Examples
// - Logged-in user (auth)
// - Current locale / language
// - Feature flags
// - Theme (light/dark)
The decision matrix โ one clear rule per category
| Category | Right Tool | Why |
|---|---|---|
| Local UI state | useState / useReducer |
Belongs to the component. No need to share. |
| Shared client state | Zustand | Selectors prevent unnecessary re-renders. Lightweight. |
| Server state | React Query (TanStack Query) | Caching, revalidation, loading/error states built-in. |
| Global app config | Context API | Changes rarely. Simple. Zero dependencies. |
This is the framework. Before you touch state โ ask which category it belongs to. Then use the right tool.
What the full stack looks like together
Here's what a well-architected state layer looks like in a real app:
React Query โ all API data (users, products, orders, etc.)
Zustand โ cart, UI state, notifications, wizard steps
Context API โ auth user, theme, locale
useState/useReducer โ everything local to a single component
In practice, inside a component:
function ProductsPage() {
// Server state โ React Query
const { data: products, isLoading } = useProducts();
// Shared client state โ Zustand
const addToCart = useCartStore(state => state.addItem);
const cartCount = useCartStore(state => state.totalItems());
// Global config โ Context
const { theme } = useTheme();
// Local UI state โ useState
const [searchQuery, setSearchQuery] = useState('');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// Derived state โ just compute it, no state needed
const filteredProducts = products
?.filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
.sort((a, b) => sortOrder === 'asc' ? a.price - b.price : b.price - a.price);
if (isLoading) return <Spinner />;
return (
<div className={theme}>
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search..." />
<button onClick={() => setSortOrder(o => o === 'asc' ? 'desc' : 'asc')}>
Sort: {sortOrder}
</button>
<div>Cart: {cartCount} items</div>
{filteredProducts?.map(product => (
<ProductCard key={product.id} product={product} onAddToCart={addToCart} />
))}
</div>
);
}
Each piece of state is in exactly the right place. Nothing is over-engineered. Nothing is under-engineered.
The most common mistakes โ and how to avoid them
Mistake 1 โ Putting server data in Zustand or Redux
// โ Managing server state manually in Zustand
const useProductsStore = create((set) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true });
try {
const data = await fetch('/api/products').then(r => r.json());
set({ products: data, isLoading: false });
} catch (err) {
set({ error: err.message, isLoading: false });
}
},
}));
You've just written a worse version of React Query. No caching. No deduplication. No background refetch. No retry. Use React Query.
Mistake 2 โ Using Context for frequently-changing state
// โ Cart in Context โ every consumer re-renders on every cart change
const CartContext = createContext({ items: [], addItem: () => {} });
function CartProvider({ children }) {
const [items, setItems] = useState([]);
// Every component consuming CartContext re-renders when items changes
return <CartContext.Provider value={{ items, addItem }}>{children}</CartContext.Provider>;
}
Cart changes frequently โ every add/remove/update. Context will re-render every consumer. Use Zustand.
Mistake 3 โ Over-globalising local state
// โ Putting this in Zustand when only one component needs it
const useModalStore = create((set) => ({
isConfirmModalOpen: false,
setConfirmModalOpen: (val) => set({ isConfirmModalOpen: val }),
}));
// โ
It's local to one component โ just use useState
function DeleteButton({ onDelete }: { onDelete: () => void }) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
// ...
}
Not everything needs to be global. If only one component needs it โ useState. Simple.
Mistake 4 โ Derived state stored as state
// โ Storing something you could just compute
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
useEffect(() => {
setFilteredUsers(users.filter(u => u.active));
}, [users]);
// โ
Just compute it during render โ no state, no effect
const filteredUsers = users.filter(u => u.active);
If a value can be derived from existing state or props โ don't store it as state. Compute it. One source of truth, no sync bugs.
The state audit โ how to review any codebase
When I join a new project or do a code review, I run through every piece of state and ask:
- Who owns this? One component, or multiple?
- Where does it come from? The server, or the client?
- How often does it change? Rarely, or constantly?
- Is it derived? Can I compute it from something that already exists?
| Answer | Action |
|---|---|
| One component owns it | useState / useReducer |
| Multiple components, client-owned, changes often | Zustand |
| Comes from the server | React Query |
| Truly global, changes rarely | Context API |
| Can be computed from existing state | Delete it โ just compute |
Run through this for every piece of state in a codebase and you'll find most of the over-engineered or under-engineered state in minutes.
A real-world state architecture โ e-commerce app
Let me make this concrete. Here's how I'd design the state layer for a typical e-commerce app:
React Query
โโโ useProducts() โ product catalogue from /api/products
โโโ useProduct(id) โ single product detail
โโโ useOrders() โ order history from /api/orders
โโโ useCreateOrder() โ POST /api/orders mutation
โโโ useUpdateProfile() โ PATCH /api/users/:id mutation
Zustand
โโโ useCartStore โ items[], addItem, removeItem, updateQty, clearCart
โโโ useUIStore โ isSidebarOpen, activeModal, toastMessages
โโโ useCheckoutStore โ step (1/2/3), shippingAddress, paymentMethod
Context API
โโโ AuthProvider โ currentUser, login(), logout()
โโโ ThemeProvider โ theme, toggleTheme()
useState (local, inside components)
โโโ SearchPage โ searchQuery, sortOrder, currentPage
โโโ ProductCard โ isHovered, isImageLoaded
โโโ AddressForm โ form field values, validation errors
Every piece of state has a clear home. Nothing is in the wrong place. New team members can look at this and immediately know where to find or add any state.
The rule worth memorising
If you remember one thing from this whole week:
State should live as close to where it's used as possible โ and no closer to the top than necessary.
Start local. Lift only when you have to. And when you do lift โ put it in the right layer based on what kind of state it is.
Local โ useState
Shared client โ Zustand
Server data โ React Query
Rarely-changing global โ Context
Week 2 wrap-up
This week we covered: 
See you Monday for Day 11 โ JWT vs Sessions vs Cookies: what they actually are and when to use each.
โ Day 9 โ Server State & React Query โ Day 11 โ JWT vs Sessions vs Cookies
Part of the React System Design Series โ 40 days, one topic per day.






