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
| Kind | Example in a coffee shop app | Best home |
|---|---|---|
| Local UI state | Is the dropdown open? Text typed in a search box | useState inside the component |
| Shared UI state | The cart, the selected pickup time | Lift up, Context or Zustand |
| Server state | The menu, an order fetched from the API | TanStack Query (or similar) |
| URL state | Selected category, page number, order id | The URL (router params and search params) |
| Form state | Values and errors while filling a form | A 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.
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.
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
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.
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.
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.
npm install @tanstack/react-queryimport { 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.
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.
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
| Situation | Use |
|---|---|
| One component needs it | useState |
| Two nearby components need it | Lift 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 API | TanStack Query |
| Something the user should be able to bookmark or share | The 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).
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
- Classify state first: local, shared client, server, URL, form. Then choose the tool.
- Start with
useStateand lifting state up; add more only when prop drilling hurts. - Context suits rarely changing values; every consumer re-renders when its value changes.
- Zustand gives shared client state with selectors, so components re-render only for what they use.
- Server state belongs in TanStack Query (cache, loading, errors, refetch), not in your own store.
- 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.