Web Development with React
ConceptIntermediate 25 min· Building real apps · 15 of 21

Managing app state: Context, Zustand and server state

Learn to sort state into kinds and pick the right tool for each: useState, lifting up, Context, Zustand, or TanStack Query for data from a server. Avoid the most common mistake: putting everything in one global store.

In small apps, useState is enough. As an app grows, you hit questions like: the cart is needed in the header, the menu page and the checkout page. Who owns it? How do I get it there without passing props through ten components?

The answer is not "install a state library". The answer is: first decide what kind of state you have. Different kinds need different tools. Most apps that feel messy have the right tools but used for the wrong kind of state.

Five kinds of state

Sort your state before choosing a tool

KindExample in a coffee shop appBest home
Local UI stateIs the dropdown open? Text typed in a search boxuseState inside the component
Shared UI stateThe cart, the selected pickup timeLift up, Context or Zustand
Server stateThe menu, an order fetched from the APITanStack Query (or similar)
URL stateSelected category, page number, order idThe URL (router params and search params)
Form stateValues and errors while filling a formA form library or useState

Server state deserves special attention. It is data that lives on a server, that you only have a copy of, and that can become out of date. It needs loading and error states, caching and refetching. This is very different from the cart, which your app owns completely. Mixing the two in one store is the biggest source of messy code.

Step 1: keep state local, then lift it

Start with useState in the component that needs it. If two siblings need the same value, move it to their closest common parent and pass it down as props. This is called lifting state up and it is the simplest tool. Do it first; reach for anything else only when it hurts.

It hurts when you pass a prop through many components that do not use it. That is called prop drilling.

Prop drilling (the pain)
function App() {
  const [cart, setCart] = useState([]);
  return <Layout cart={cart} setCart={setCart} />;
}

// Layout does not use cart, it only forwards it
function Layout({ cart, setCart }) {
  return <MenuPage cart={cart} setCart={setCart} />;
}

// MenuPage forwards it again...
function MenuPage({ cart, setCart }) {
  return <AddButton setCart={setCart} />;
}

Two or three levels is fine. Five levels of forwarding is the sign to use Context or a store.

Step 2: Context for values that rarely change

Context lets a parent make a value available to every component below it, without props. It is built into React. It is a good fit for things that change rarely: the theme, the logged-in user, the language.

There are three parts: create the context, provide a value, and read it with useContext. Wrapping the last part in a small custom hook keeps the rest of your code tidy.

src/CartContext.jsx
import { createContext, useContext, useState } from 'react';

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);

  function addItem(item) {
    setItems((current) => {
      const existing = current.find((i) => i.id === item.id);
      if (existing) {
        return current.map((i) =>
          i.id === item.id ? { ...i, qty: i.qty + 1 } : i
        );
      }
      return [...current, { ...item, qty: 1 }];
    });
  }

  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);

  return (
    <CartContext.Provider value={{ items, addItem, total }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart() {
  const value = useContext(CartContext);
  if (value === null) {
    throw new Error('useCart must be used inside <CartProvider>');
  }
  return value;
}

The error check gives a clear message if you forget the provider, instead of a confusing 'cannot read property of null'.

src/main.jsx and a consumer
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import { CartProvider } from './CartContext.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <CartProvider>
      <App />
    </CartProvider>
  </StrictMode>
);

// src/CartBadge.jsx
import { useCart } from './CartContext.jsx';

export default function CartBadge() {
  const { items, total } = useCart();
  return <span>{items.length} items, ₹{total}</span>;
}

Any component under CartProvider can call useCart(), however deep it is.

Context re-renders every consumer

When the value of a context changes, every component that calls useContext for it re-renders, even if it only uses one field. For a theme or a user this is fine. For something that changes often (a cart in a big app, mouse position, form input on every keystroke) it can cause slow screens. Also, passing value={{ items, addItem }} creates a new object on each render of the provider. Keep the provider's own state small and its position in the tree low.

Step 3: Zustand for shared client state

Zustand is a small library (a few KB) for shared client state. You create a store with a function, and components subscribe to just the slice they need. Unlike Context, a component re-renders only when the slice it selected changes. There is no provider to wrap around your app.

Install it with npm install zustand.

src/cartStore.js
import { create } from 'zustand';

export const useCartStore = create((set) => ({
  items: [],

  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, qty: i.qty + 1 } : i
          ),
        };
      }
      return { items: [...state.items, { ...item, qty: 1 }] };
    }),

  clear: () => set({ items: [] }),
}));

set receives the new partial state (or a function of the old state) and merges it in. The hook returned by create() is the only thing components import.

Using the store
import { useCartStore } from './cartStore.js';

function CartBadge() {
  // Select only what you need: re-renders only when the count changes
  const count = useCartStore((state) =>
    state.items.reduce((n, i) => n + i.qty, 0)
  );
  return <span>Cart ({count})</span>;
}

function AddButton({ item }) {
  // The action never changes, so this component does not re-render on cart changes
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(item)}>Add</button>;
}

Selecting a number or a function (a primitive or a stable value) is safe. Selecting a new object or array on every call, such as state => ({ a: state.a }), can cause extra re-renders.

Redux and others

Redux Toolkit is the other common choice, and many existing company codebases use it, so you should recognise it: a store, slices, actions and reducers. It does the same job as Zustand with more structure and more code. For a new small or medium project, Zustand is usually quicker to learn. The ideas (one source of truth, updates through actions) are the same.

Step 4: server state with TanStack Query

Fetching data with useEffect, useState for data, loading and error works (you learned it in *useEffect and fetching data*). But in a real app you also want: caching so going back to a page is instant, refetching when stale, de-duplicating the same request from two components, retries, and a refresh after you change data. Writing that by hand is tedious and easy to get wrong.

TanStack Query (package @tanstack/react-query) does all this. You describe each piece of data with a query key and a function that fetches it.

Terminal
npm install @tanstack/react-query
src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App.jsx';

const queryClient = new QueryClient();

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>
);

One QueryClient holds the cache for the whole app.

src/Menu.jsx: reading server state
import { useQuery } from '@tanstack/react-query';

async function fetchMenu() {
  const res = await fetch('http://localhost:8000/menu');
  if (!res.ok) throw new Error('Could not load the menu');
  return res.json();
}

export default function Menu() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['menu'],
    queryFn: fetchMenu,
  });

  if (isPending) return <p>Loading menu...</p>;
  if (isError) return <p>Something went wrong: {error.message}</p>;

  return (
    <ul>
      {data.map((item) => (
        <li key={item.id}>{item.name} - ₹{item.price}</li>
      ))}
    </ul>
  );
}

fetch only rejects on network failure, so check res.ok yourself and throw; otherwise a 500 response looks like success. Any component that calls useQuery with the key ['menu'] shares one cached request.

Changing server data with useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query';

async function placeOrder(order) {
  const res = await fetch('http://localhost:8000/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(order),
  });
  if (!res.ok) throw new Error('Order failed');
  return res.json();
}

export function CheckoutButton({ order }) {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: placeOrder,
    onSuccess: () => {
      // Mark cached orders as out of date so they are refetched
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });

  return (
    <button disabled={mutation.isPending} onClick={() => mutation.mutate(order)}>
      {mutation.isPending ? 'Placing...' : 'Place order'}
    </button>
  );
}

invalidateQueries is the key idea: after a change, tell the cache which data is now stale and it refetches it.

Putting it together

In the Brew & Bean app, a healthy split looks like this: the menu and orders come from the API through TanStack Query; the cart lives in Zustand (or Context); the chosen category is in the URL (/menu?category=coffee); the open/closed state of a modal is a useState in the component. Nothing is copied between them. If you catch yourself copying API data into a store with useEffect, stop: let the query cache own it.

Quick chooser

SituationUse
One component needs ituseState
Two nearby components need itLift state to the parent
A rarely changing value needed everywhere (theme, user)Context
Often-changing shared client state (cart, filters, UI panels)Zustand or Redux Toolkit
Data from an APITanStack Query
Something the user should be able to bookmark or shareThe URL

How this comes up in interviews

Expect: "What is prop drilling and how do you avoid it?", "Context vs Redux?", "When does Context cause performance problems?" and "How do you manage data fetched from an API?". A strong answer starts by classifying the state (local, shared, server, URL) and then chooses the simplest tool, instead of naming a library first. Mention that you would not store server data in Redux or Zustand by default.

Try it: Classify and refactor

Take any app you built earlier (or the Brew & Bean menu app) and write down every piece of state in it. Label each as local, shared UI, server, URL or form. Then:

1. Move the cart into a Zustand store and show the item count in the header. 2. Replace one useEffect fetch with useQuery. 3. Open the app in two places that show the menu and confirm only one network request is made (browser DevTools, Network tab).

Prompt · Review my state design
You are a senior React developer. Here is a list of the state in my app and where it lives now:

[paste your list: name, what it holds, where it is stored]

For each item, say whether it is local UI state, shared client state, server state, URL state or form state. Then recommend the simplest place for it (useState, lifting up, Context, Zustand, TanStack Query or the URL) and explain why in one sentence. Point out any state that is duplicated or derived and should be calculated instead of stored.

What to expect: Check that server data is not copied into a global store, and that values like 'total' are computed from items rather than stored separately.

Key takeaways

  1. Classify state first: local, shared client, server, URL, form. Then choose the tool.
  2. Start with useState and lifting state up; add more only when prop drilling hurts.
  3. Context suits rarely changing values; every consumer re-renders when its value changes.
  4. Zustand gives shared client state with selectors, so components re-render only for what they use.
  5. Server state belongs in TanStack Query (cache, loading, errors, refetch), not in your own store.
  6. Derive values (like totals) instead of storing them twice.

Check yourself

Finishing the quiz marks the lesson as done.

1. In your app, the menu is loaded from an API and shown on three pages. Where should this data live?

2. What is a real downside of putting frequently changing data in React Context?

3. A search box's text is only used by that one component. What should hold it?

4. After a POST to /orders succeeds, you want the cached orders list to update. What do you call in TanStack Query?

5. A user should be able to share a link that opens the menu already filtered to 'tea'. Where should the selected category be stored?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free