Using Zustand for Local and Global State in React
A practical guide to structuring Zustand stores, optimizing subscriptions, and integrating lightweight state management into modern React applications.
Using Zustand for Local and Global State in React
Zustand is a small state management library that gives you predictable global state without the ceremony of reducers, actions, or immutable update utilities. It uses plain JavaScript objects and functions, which makes it ideal for UI state or business logic that feels too heavy for Redux but too cross cutting for local component state.
This guide shows how to structure stores, optimize subscriptions, and integrate Zustand into modern React applications in a way that stays maintainable at scale.
1. Why Zustand Works Well With React
Zustand is built on a simple model:
1. Your state is a plain object.
2. You update it through a set function.
3. Components subscribe to exactly the slice they care about.
This leads to:
- fewer unnecessary re renders
- a simple mental model for updates
- easy refactoring when features grow
- no need for reducers or action types unless you want them
Zustand fits nicely between local useState and a full Redux style architecture.
2. Creating a Basic Store
A Zustand store is created with create. It returns a hook you can call in any component.
import { create } from "zustand";
type CartItem = {
id: string;
name: string;
price: number;
};
type CartState = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clear: () => void;
};
export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id != id)
})),
clear: () => set({ items: [] })
}));Usage in a component:
function CartSummary() {
const items = useCartStore((state) => state.items);
const clear = useCartStore((state) => state.clear);
const total = items.reduce((sum, item) => sum + item.price, 0);
return (
<section>
<p>Items: {items.length}</p>
<p>Total: {total}</p>
<button onClick={clear}>Clear cart</button>
</section>
);
}Each selector subscribes to only what it needs, which keeps renders tight.
3. Use Selectors Instead of Subscribing to the Whole Store
The quickest way to hurt performance is to subscribe to the entire store in every component.
Bad:
const state = useCartStore(); // component re renders for any change in the storeGood:
const items = useCartStore((state) => state.items);
const addItem = useCartStore((state) => state.addItem);Zustand uses reference equality on the selected slice. If the selected value does not change, the component does not re render.
Derived selector example
You can compute derived values directly in the selector:
const totalPrice = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price, 0)
);This keeps derived logic close to where it is used without storing it in the state object.
4. Splitting Stores by Domain
You do not need a single global store. In fact, it is often cleaner to create several small stores by domain.
Example folder structure:
src/
stores/
cartStore.ts
authStore.ts
uiStore.tsExample UI store:
type UIState = {
isSidebarOpen: boolean;
toggleSidebar: () => void;
closeSidebar: () => void;
};
export const useUIStore = create<UIState>((set) => ({
isSidebarOpen: false,
toggleSidebar: () =>
set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
closeSidebar: () => set({ isSidebarOpen: false })
}));Small, focused stores keep mental overhead low and match how features are organized in the UI.
5. Handling Async Logic in the Store
Zustand does not care whether your updater is sync or async. You can call set before and after an async operation.
type User = {
id: string;
email: string;
};
type AuthState = {
user: User | null;
loading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
};
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: false,
error: null,
login: async (email, password) => {
set({ loading: true, error: null });
try {
const user = await api.login({ email, password });
set({ user, loading: false, error: null });
} catch (err: any) {
set({
loading: false,
error: err?.message ?? "Login failed"
});
}
},
logout: () => {
set({ user: null });
}
}));Components stay very simple:
function LoginForm() {
const login = useAuthStore((state) => state.login);
const loading = useAuthStore((state) => state.loading);
const error = useAuthStore((state) => state.error);
// call login(email, password) on submit
}The store owns the workflow, and the component focuses on rendering.
6. Persisting State With Middleware
For settings, theme, or recent items, it is useful to persist state between page reloads. Zustand provides a persist middleware that uses localStorage by default.
import { create } from "zustand";
import { persist } from "zustand/middleware";
type ThemeState = {
theme: "light" | "dark";
toggleTheme: () => void;
};
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: "light",
toggleTheme: () =>
set((state) => ({
theme: state.theme == "light" ? "dark" : "light"
}))
}),
{
name: "theme-storage"
}
)
);The store will:
- read the initial value from
localStorage - write changes automatically
- expose the same API to your components
This is perfect for preferences and small persistent UI state.
7. Using Zustand for UI and Layout State
Zustand is especially good at replacing complex Context trees for UI concerns.
Examples:
- dialogs and modals
- global toasts and notifications
- multi step flows
- filter panels
- split pane layouts
Modal store example:
type ModalState = {
isOpen: boolean;
open: () => void;
close: () => void;
};
export const useModalStore = create<ModalState>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false })
}));Usage:
function Modal() {
const isOpen = useModalStore((state) => state.isOpen);
const close = useModalStore((state) => state.close);
if (!isOpen) return null;
return (
<div className="backdrop">
<div className="dialog">
<button onClick={close}>Close</button>
</div>
</div>
);
}Any component can open or close the modal without prop drilling.
8. Do Not Store Derived State
A common mistake is to store values that can be computed from other fields.
Bad:
type CartState = {
items: CartItem[];
total: number; // redundant
};Now every update must remember to keep total in sync.
Instead, derive it where needed:
const total = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price, 0)
);This follows the same principles as React itself: derive state when possible rather than duplicating it.
9. Middleware for Debugging and Advanced Usage
Zustand supports several middleware helpers.
devtoolsintegrates with Redux DevTools.immerlets you write mutable updates with structural sharing under the hood.subscribeWithSelectorallows fine grained subscriptions outside components.
Example with devtools:
import { devtools } from "zustand/middleware";
type CounterState = {
count: number;
increment: () => void;
};
export const useCounterStore = create<CounterState>()(
devtools((set) => ({
count: 0,
increment: () =>
set((state) => ({
count: state.count + 1
}))
}))
);You can inspect state transitions using the Redux DevTools extension without rewriting your codebase to Redux.
10. When Zustand Is Not the Right Tool
Zustand is powerful, but it is not universal. Consider other tools when:
- you need strict event sourced reducers and time travel debugging
- your workflows are complex enough to benefit from state machines
- many teams collaborate on a very large domain model that needs strong conventions
In those cases, Redux Toolkit or XState can be a better fit.
For most medium sized applications and for UI state that would otherwise live in Context, Zustand is more than enough.
Final Thoughts
Zustand gives you a lightweight but powerful way to share state in React without heavy abstractions. By:
- structuring stores by domain
- using selectors for precise subscriptions
- keeping derived data out of the store
- placing async logic inside the store instead of components
you end up with a clean state layer that is easy to test and reason about.
Used alongside React Query for server state and Redux Toolkit for complex workflows, Zustand fits naturally into a modern React architecture focused on clear boundaries and predictable behavior.