How to use Zustand as Modular State Management in React apps


I've been using React Context for quite a while, but I often ran into challenges with managing state and dealing with unnecessary re-renders. I started exploring various state management libraries that can provide a neat way to handle state management without performance issues. One key thing I was looking for was modular state management because not everything needs to live in a global store, and that's something I ran into often.
Once I landed to Zustand, I stopped. It has a very simple API, provides good performance, and no boilerplate. So in this post, I’ll walk you through how Zustand works and how you can use it effectively in your projects specially in case of reusability.
Introduction
Zustand 🧸 is a powerful and lightweight library for managing global client-side state in React applications. It can be paired with React Query (for server state) to provide a clean and modern approach to handling state in your frontend stack.
It is simple, fast, and tiny (around 1KB minified). But I think what really makes it shine is how well it integrates with modern React patterns and allows for both global and modular state management with minimal boilerplate.
Zustand mostly known for Global State management
Zustand creates a global store that exists outside the React component tree. This means any component, regardless of its position in the hierarchy, can access and share state without the need to lift or pass props manually.
🔍 So what does that mean exactly?
In typical React apps, state is either:
Local (using
useState
oruseReducer
) and tied to a single componentLifted to a common parent to share across multiple components
With Zustand, however, state is centralized in a global store. This makes it incredibly easy to share state between deeply nested components.
Zustand works per-property subscription basis
One of the most powerful features of Zustand is how it handles subscriptions **at a fine-grained level (**on a per-property basis).
When you use Zustand in a component like this:
const count = useStore((state) => state.count);
The component only subscribes to the state.count
selector, and nothing else. This means:
If any other part of the state changes, this component won't re-render.
Zustand decides when to notify your component about changes in the state it cares about by comparing the current selector's output with the output from the previous render.
Why does this matter?
This fine-grained subscription system provides:
Fewer checks and re-renders throughout the app.
Components only respond to the specific data they need.
No need for memoization of selectors, unlike Redux.
Zustand achieves this using a technique called selector-based/per-property based subscriptions. It listens to the return value of your selector (like state.count
) and only triggers a re-render when that specific value changes.
For reusability, use Zustand as modular state management
Since Zustand stores are global by default, meaning all states or updaters are accessible from anywhere in the app. But in many cases, you might not want that. For example, there is usually no need for components in the dashboard module to access the states of the notification module unless there is a specific reason. By keeping unrelated states isolated, improves maintainability and avoids unnecessary re-renders or accidental data sharing.
To handle this, Zustand provides a way to create store instances instead of using a single global store. You can create a custom Zustand store instance and provide it via React Context. This way, only components wrapped by that context provider can access and modify the store.
This pattern is useful when you want modular, isolated state logic, like having separate stores for forms, modals, feature flags, or specific routes. Each module manages its own state without depending on a global store.
Zustand’s docs suggest this approach as a best practice for use cases where you need modular state management rather than a single shared global state.
Let’s look at an example: a notification system.
In many applications, notifications are displayed in a specific area, such as a bell icon in the header or a sidebar. There's usually no need for components outside this area to be aware of the notification state.
To keep things modular, we can create a scoped Zustand store instance for managing notifications.
Note: Zustand context was a built-in feature before v5, but it was removed to keep the Zustand package as tiny as possible.
const NotificationStoreContext = React.createContext(null)
export const NotificationStoreProvider = ({ children }) => {
const [store] = React.useState(() =>
createStore((set) => ({
notifications: [],
actions: {
receiveNotification: (msg) =>
set((state) => ({
notifications: [
...state.notifications,
{ id: Date.now(), msg, read: false },
],
})),
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
),
})),
markAllAsRead: () =>
set((state) => ({
notifications: state.notifications.map((n) => ({
...n,
read: true,
})),
})),
},
}))
)
return (
<NotificationStoreContext.Provider value={store}>
{children}
</NotificationStoreContext.Provider>
)
}
const useNotificationStore = (selector) => {
const store = React.useContext(NotificationStoreContext)
if (!store) throw new Error('NotificationStoreProvider is missing')
return useStore(store, selector)
}
export const useNotifications = () => useNotificationStore((state) => state.notiifcations));
export const useNotificationActions = () => useNofiticationStore((state) => state.actions));
This store tracks notifications, allows receiving new ones with receiveNotification
, lets you mark individual notifications as read using markAsRead
, and offers a markAllAsRead
action for bulk updates.
The store is scoped using React Context, so only components wrapped inside the NotificationStoreProvider
can access and modify this state.
<NotificationStoreProvider>
<NotificationBell /> // Shows unread count
<NotificationList /> // Lists all notifications
<MarkAllReadButton /> // Marks all as read
</NotificationStoreProvider>
NotificationBell
– Displays a badge with unread count.NotificationList
– RendersNotificationItem
and each item has the ability to mark a message read.MarkAllReadButton
– Triggers a bulk action to mark all messages as read.
This Zustand context-based pattern ensures that only the notification UI components respond to changes in the store, so nothing else in your app is affected.
Bonus: With immer
, you can avoid the need to manually spread objects, which is especially helpful when you are dealing with states that contain large datasets. This library simplifies state management by allowing you to work with immutable data structures more easily. This can also reduce the complexity and potential errors in your code base.
Conclusion
Zustand provides a flexible and efficient approach to client state management in React applications. Its ability to handle both global and modular state with minimal boilerplate makes it a valuable tool for developers.
You can leverage per-property subscriptions and context-based pattern in Zustand to ensures that components only respond to the state changes that they care about. This will enhance maintainability and improve performance. Whether you're managing a simple global state or implementing a more complex modular architecture, Zustand provides the tools needed to keep your application state organized and responsive.
Subscribe to my newsletter
Read articles from Subrato Pattanaik directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Subrato Pattanaik
Subrato Pattanaik
I'm a Software engineer with over 4 years of experience working with React JS, the most popular JavaScript UI library. Exploring the world of full stack, including AI.