Skip to main content

Command Palette

Search for a command to run...

Designing Your State Layer โ€” Where to Put What and Why

Day 10 of 40 โ€” React System Design Series

Updated
โ€ข8 min read
Designing Your State Layer โ€” Where to Put What and Why
R

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! ๐Ÿ‘‹

architect gif

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:

  1. Who owns this? One component, or multiple?
  2. Where does it come from? The server, or the client?
  3. How often does it change? Rarely, or constantly?
  4. 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: ![movie](

  • Day 6 โ€” Context API: the right tool for rarely-changing globals, and why it breaks with frequent updates
  • Day 7 โ€” Zustand: the lightweight store with selectors that prevents unnecessary re-renders
  • Day 8 โ€” Redux Toolkit: what it fixed about old Redux and when it's still the right call
  • Day 9 โ€” React Query: server state is a cache, not your state โ€” let the right tool manage it
  • Day 10 โ€” State architecture: the decision framework that ties it all together

Next week we go deep into Authentication โ€” the area where most tutorials lie to you and where production apps need to get it right. JWT vs sessions vs cookies, protected routes, silent refresh โ€” the full story.

week done gif

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.

React System Design โ€” Learning in Public

Part 10 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js โ€” and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing โ€” the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

JWT vs Sessions vs Cookies โ€” What They Actually Are and When to Use Each

Day 11 of 40 โ€” React System Design Series