Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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 zustand

Creating 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:

src/store/authStore.js
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():

src/App.jsx
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:

src/components/ProtectedRoute.jsx
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;
src/pages/LoginPage.jsx
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.

src/main.jsx
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).