Global State with Zustand in React
Global State with Zustand
~16 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
Zustand (German for "state", but the library's actual name, pronounced "tsoo-shtahnt" – not a coincidence, the author is German-speaking) is a very small, very popular state management library that solves exactly the problem from the last chapter: targeted subscriptions instead of all-or-nothing.
Why Zustand instead of "even more Context"
You COULD also solve the re-render problem with plain Context – e.g. by creating a SEPARATE context for each independent state area (AuthContext, NotificationContext, CartContext, ...). That works, but quickly becomes unwieldy (lots of nested providers) and doesn't solve the problem WITHIN a single, naturally-related state area (e.g. "only user, not isLoading" from the SAME auth state). Zustand solves both with a single, very small concept: a store with selectors.
Installing Zustand
npm install zustandCreating src/store/authStore.js: the first store
A Zustand store is surprisingly little code – no reducers, no action types, no boilerplate. create() takes a function that returns the initial state AND the functions that change it:
import { create } from 'zustand';
export const useAuthStore = create((set) => ({
user: null,
login(username) {
set({ user: { username } });
},
logout() {
set({ user: null });
},
}));The result of create(...) is itself a React hook – hence the name useAuthStore. set(...) is Zustand's version of "update state" – it MERGES the given object into the existing state (like this.setState() in the class components of old), rather than replacing it entirely.
The key difference: selectors
Instead of always fetching the entire store (as with useAuth()), you call useAuthStore with a SELECTOR function that returns ONLY the slice you want:
// Whole thing (like Context before) - re-renders on EVERY store change:
const { user, login, logout } = useAuthStore();
// Selective (Zustand's superpower) - re-renders ONLY when 'user' changes:
const user = useAuthStore((state) => state.user);
const login = useAuthStore((state) => state.login);On every store change, Zustand compares the RESULT of each registered selector against its previous value (via Object.is, very similar to ===). Only components whose selector result ACTUALLY changed re-render – components that only subscribed to state.login (a stable function reference that never changes) NEVER re-render, no matter what else happens in the store.
Removing AuthContext.jsx, updating App.jsx
src/context/AuthContext.jsx is no longer needed – delete the file. App.jsx now uses useAuthStore with selectors instead of useAuth():
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { useDocumentTitle } from './hooks/useDocumentTitle';
import RenderCounter from './components/RenderCounter';
import ProductListPage from './pages/ProductListPage';
import ProductDetailPage from './pages/ProductDetailPage';
import ProtectedRoute from './components/ProtectedRoute';
const ReviewsPage = lazy(() => import('./pages/ReviewsPage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const AccountPage = lazy(() => import('./pages/AccountPage'));
function App() {
const user = useAuthStore((state) => state.user);
const login = useAuthStore((state) => state.login);
const logout = useAuthStore((state) => state.logout);
useDocumentTitle('Product Catalog');
return (
<div className="app">
<header className="app-header">
<h1>Product Catalog</h1>
<RenderCounter label="App header" />
{user ? (
<p>Logged in as {user.username} <button onClick={logout}>Log out</button></p>
) : (
<button onClick={() => login('Jane Doe')}>Log in</button>
)}
</header>
<Suspense fallback={<p>Loading page...</p>}>
<Routes>
<Route path="/" element={<ProductListPage />} />
<Route path="/products/:sku" element={<ProductDetailPage />}>
<Route path="reviews" element={<ReviewsPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route
path="/account"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
</Routes>
</Suspense>
</div>
);
}
export default App;Each of the three lines useAuthStore((state) => state.user) etc. is its OWN, independent subscription. Zustand is smart enough to handle multiple selector calls in the same component body efficiently, rather than doing a full new store access for each one.
Updating more consumers: ProtectedRoute and LoginPage
ProtectedRoute and LoginPage from "React for Beginners" used to call useAuth() – they're switched to selectors too:
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
function ProtectedRoute({ children }) {
const user = useAuthStore((state) => state.user);
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
export default ProtectedRoute;import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
function LoginPage() {
const login = useAuthStore((state) => state.login);
const [username, setUsername] = useState('');
const navigate = useNavigate();
function handleSubmit(event) {
event.preventDefault();
login(username || 'Jane Doe');
navigate('/account');
}
return (
<form onSubmit={handleSubmit}>
<h2>Log In</h2>
<input
type="text"
placeholder="Username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
<button type="submit">Log In</button>
</form>
);
}
export default LoginPage;Achtung: No more <AuthProvider> component needed, neither in App.jsx nor in main.jsx – that's a fundamental difference from Context. A Zustand store exists OUTSIDE the component tree (technically: as a module singleton), it never needs to "wrap" anything.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);The <AuthProvider> import and wrapper are gone entirely – <App /> now sits directly under <BrowserRouter>. That's the most visible structural difference between Context and an external store.
The proof: RenderCounter now stays quiet
Let's mentally repeat the experiment from the last chapter: as a test, add another, independent value to the store (e.g. notificationCount as before) and a button in ANY component that subscribes ONLY to state.notificationCount. You'll see: App's RenderCounter stays UNCHANGED when you click that button – App never subscribed to notificationCount in the first place. That was simply impossible with plain Context.
So when is Context still enough?
- Values that TRULY change rarely and are needed globally but aren't performance-critical (e.g. a static theme object set once at app startup).
- Very small apps/prototypes, where the extra dependency (Zustand is tiny, but still a library) isn't worth the overhead.
- Situations where you deliberately WANT all consumers to re-render in sync (rare, but they exist – e.g. certain test/Storybook scenarios).
Tipp: Rule of thumb for medium-sized projects and up: Context for rare, static configuration (theme, i18n locale), a real store (Zustand/Redux Toolkit) for EVERYTHING that actually changes frequently during use (auth status, shopping cart, form data spanning multiple screens, UI state like open modals).